From 0ae5cde3c4b7cf7e2585f239247d42b6ab0bd87a Mon Sep 17 00:00:00 2001 From: coli Date: Tue, 19 Aug 2025 02:00:58 +0900 Subject: [PATCH 01/33] =?UTF-8?q?chore:=20mcp=20client=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=EC=BD=94=EB=93=9C=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devoops/client/PrAnalysisClientImpl.java | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) 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 401becd..a65ed94 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,11 +1,16 @@ package com.devoops.client; import com.devoops.dto.response.AnalyzePrResponse; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.ResponseFormat; +import org.springframework.ai.openai.api.ResponseFormat.Type; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -17,28 +22,40 @@ public class PrAnalysisClientImpl implements PrAnalysisClient { @Value("${dev-oops.github-pr-analysis.prompt}") private String promptTemplate; - private final ChatModel chatModel; - private final ObjectMapper objectMapper; + @Value("${dev-oops.github-pr-analysis.system}") + private String systemPrompt; + + private final ChatClient chatClient; @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); + //json schema 추출 + BeanOutputConverter outputConverter = new BeanOutputConverter<>(AnalyzePrResponse.class); + String jsonSchema = outputConverter.getJsonSchema(); + + //option 설정 + OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder() + .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, jsonSchema)) + .model(OpenAiApi.ChatModel.GPT_4_O_MINI) + .build(); + + //prompt 만들고 보내기 + String userPrompt = buildPrompt(title, description, diff); + return chatClient.prompt() + .options(openAiChatOptions) + .system(systemPrompt) + .user(userPrompt) + .call() + .entity(AnalyzePrResponse.class); } private String buildPrompt(String title, String description, String diff) { - return String.format(promptTemplate, title, description, diff); + return promptTemplate + .replace("{title}", title) + .replace("{description}", description) + .replace("{diff}", encodeDiff(diff)); } - - 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 String encodeDiff(String diff) { + return Base64.getEncoder().encodeToString(diff.getBytes(StandardCharsets.UTF_8)); } } From 510fedcc6777532d1c16dfc046aed7b02cb0eebc Mon Sep 17 00:00:00 2001 From: coli Date: Tue, 19 Aug 2025 02:01:33 +0900 Subject: [PATCH 02/33] =?UTF-8?q?chore:=20chatclient=20config=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/devoops/ChatConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java new file mode 100644 index 0000000..bc389a6 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java @@ -0,0 +1,15 @@ +package com.devoops; + +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 ChatConfig { + + @Bean + public ChatClient chatClient(OpenAiChatModel chatModel) { + return ChatClient.create(chatModel); + } +} From f7bdda1eda2a41a28ed94de2c0d97b262b19b2f7 Mon Sep 17 00:00:00 2001 From: coli Date: Tue, 19 Aug 2025 02:04:06 +0900 Subject: [PATCH 03/33] =?UTF-8?q?chore:=20schema=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20required=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/AnalyzePrResponse.java | 15 +++++++------- .../src/main/resources/application-local.yml | 20 +++++++++---------- 2 files changed, 18 insertions(+), 17 deletions(-) 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 4db519c..2e30d76 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,22 +1,23 @@ package com.devoops.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; public record AnalyzePrResponse( - String summary, - List summaryDetails, - List questions + @JsonProperty(required = true) String summary, + @JsonProperty(required = true) List summaryDetails, + @JsonProperty(required = true) List questions ) { public record SummaryDetails( - String title, - String description + @JsonProperty(required = true) String title, + @JsonProperty(required = true) String description ) { } public record CategorizedQuestion( - String category, - List question + @JsonProperty(required = true) String category, + @JsonProperty(required = true) List question ) { } } diff --git a/gss-mcp-app/src/main/resources/application-local.yml b/gss-mcp-app/src/main/resources/application-local.yml index 97bef2f..6310059 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 From 25891a2b8ebb0f9914e793c14bd3b7fd4ad964a4 Mon Sep 17 00:00:00 2001 From: coli Date: Tue, 19 Aug 2025 02:06:47 +0900 Subject: [PATCH 04/33] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/resources/application-mcp-client.yml | 61 ++++++------------- .../src/test/resources/application.yml | 60 ++++++------------ 2 files changed, 40 insertions(+), 81 deletions(-) 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 cc9a5ba..df137dd 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,50 +1,29 @@ 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: diff --git a/gss-mcp-app/src/test/resources/application.yml b/gss-mcp-app/src/test/resources/application.yml index 908e450..f4a96b8 100644 --- a/gss-mcp-app/src/test/resources/application.yml +++ b/gss-mcp-app/src/test/resources/application.yml @@ -36,47 +36,27 @@ 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 요약과 질문을 만들어 주세요. From 6f2d036a9e64fea50576a1062d496582c32b1a4a Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 20 Aug 2025 22:53:19 +0900 Subject: [PATCH 05/33] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-mcp-client.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 df137dd..3e740d2 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 @@ -24,7 +24,9 @@ dev-oops: --- END DIFF --- diff를 Base64에서 디코딩한 후 분석하고 PR 요약과 질문을 만들어 주세요. + --- + spring: config: activate: @@ -32,12 +34,9 @@ spring: ai: openai: api-key: testKey - chat: - options: - model: gpt-4o-mini - --- + spring: config: activate: From 612632cdf2483fb2b3f20ba5d0eafd52315942f7 Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 20 Aug 2025 22:54:01 +0900 Subject: [PATCH 06/33] =?UTF-8?q?chore:=20AI=20PR=20=EB=B6=84=EC=84=9D?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devoops/client/PrAnalysisClientImpl.java | 28 ++++++++++++++++--- .../devoops/etc/OpenaiPropertiesLogger.java | 4 --- .../com/devoops/PrAnalysisClientImplTest.java | 5 +++- 3 files changed, 28 insertions(+), 9 deletions(-) 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 a65ed94..6e26788 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,14 +1,18 @@ package com.devoops.client; import com.devoops.dto.response.AnalyzePrResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.Base64; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClient.CallResponseSpec; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.ResponseFormat; import org.springframework.ai.openai.api.ResponseFormat.Type; import org.springframework.beans.factory.annotation.Value; @@ -36,17 +40,32 @@ public AnalyzePrResponse analyze(String title, String description, String diff) //option 설정 OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder() .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, jsonSchema)) - .model(OpenAiApi.ChatModel.GPT_4_O_MINI) + .model("gpt-5-nano") + .reasoningEffort("medium") + .temperature(1.0) .build(); //prompt 만들고 보내기 String userPrompt = buildPrompt(title, description, diff); - return chatClient.prompt() + ChatResponse chatresponse = chatClient.prompt() .options(openAiChatOptions) .system(systemPrompt) .user(userPrompt) .call() - .entity(AnalyzePrResponse.class); + .chatResponse(); + + + Usage usage = chatresponse.getMetadata().getUsage(); + System.out.println("Prompt tokens: " + usage.getPromptTokens()); + System.out.println("Completion tokens: " + usage.getCompletionTokens()); + System.out.println("Total tokens: " + usage.getTotalTokens()); + + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.readValue(chatresponse.getResult().getOutput().getText(), AnalyzePrResponse.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } private String buildPrompt(String title, String description, String diff) { @@ -55,6 +74,7 @@ private String buildPrompt(String title, String description, String diff) { .replace("{description}", description) .replace("{diff}", encodeDiff(diff)); } + 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/etc/OpenaiPropertiesLogger.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/etc/OpenaiPropertiesLogger.java index f3b59b6..43517f0 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-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java index eaaa0a3..fd1c212 100644 --- a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java +++ b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java @@ -32,8 +32,11 @@ void analyzePr_shouldReturnSummaryAndQuestions() { + userRepository.save(user); + } """; - + long startTime = System.currentTimeMillis(); AnalyzePrResponse result = prAnalysisClient.analyze(title, desc, diff); + long endTime = System.currentTimeMillis(); + + System.out.println(endTime - startTime+ "ms"); System.out.println("📝 요약: " + result.summary()); result.summaryDetails().forEach(q -> System.out.println("- " + q)); From b455cc696b6ec65b250be108e8a01cd07372c5e5 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 00:47:42 +0900 Subject: [PATCH 07/33] =?UTF-8?q?feat:=20Aiconfig=EB=A1=9C=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/devoops/ChatConfig.java | 15 -------- .../java/com/devoops/config/AiConfig.java | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 15 deletions(-) delete mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java create mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/config/AiConfig.java diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java deleted file mode 100644 index bc389a6..0000000 --- a/gss-client/gss-mcp-client/src/main/java/com/devoops/ChatConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devoops; - -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 ChatConfig { - - @Bean - public ChatClient chatClient(OpenAiChatModel chatModel) { - return ChatClient.create(chatModel); - } -} 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 0000000..ead82c0 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/config/AiConfig.java @@ -0,0 +1,34 @@ +package com.devoops.config; + +import com.devoops.dto.response.PrAnalysis; +import org.springframework.ai.chat.client.ChatClient; +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.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AiConfig { + + @Bean + public OpenAiChatOptions.Builder openAiChatBuilder() { + return OpenAiChatOptions.builder() + .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, outputJsonSchema())) + .reasoningEffort("medium") + .temperature(1.0); + + } + + public String outputJsonSchema() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(PrAnalysis.class); + return outputConverter.getJsonSchema(); + } + + @Bean + public ChatClient chatClient(OpenAiChatModel chatModel) { + return ChatClient.create(chatModel); + } +} From 4ec1670bf07e3fb71c9c128592eecf0db03d3bd1 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 00:48:02 +0900 Subject: [PATCH 08/33] =?UTF-8?q?feat:=20PromptBuilder=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devoops/client/PromptBuilder.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/client/PromptBuilder.java 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 0000000..148c9e4 --- /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)); + } +} From 1804e2d5597c9162153004cde81d97535f66fabe Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 00:49:13 +0900 Subject: [PATCH 09/33] =?UTF-8?q?feat:=20PR=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devoops/client/PrAnalysisClient.java | 4 +- .../devoops/client/PrAnalysisClientImpl.java | 78 +- .../devoops/dto/request/AnalyzePrRequest.java | 10 + .../dto/response/AnalyzePrResponse.java | 37 +- .../com/devoops/dto/response/PrAnalysis.java | 24 + .../devoops/adaptor/PrAnalysisAdapter.java | 19 +- .../dto/request/AdaptedAnalyzePrResponse.java | 6 +- .../devoops/event/QuestionEventListener.java | 6 +- .../com/devoops/PrAnalysisClientImplTest.java | 934 +++++++++++++++++- .../devoops/fake/FakePrAnalysisClient.java | 21 +- 10 files changed, 1035 insertions(+), 104 deletions(-) create mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/dto/request/AnalyzePrRequest.java create mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/PrAnalysis.java 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 51f8cfb..f587f86 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 6e26788..7b75af6 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,21 +1,14 @@ package com.devoops.client; +import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.nio.charset.StandardCharsets; -import java.util.Base64; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.ChatClient.CallResponseSpec; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.ResponseFormat; -import org.springframework.ai.openai.api.ResponseFormat.Type; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component @@ -23,59 +16,38 @@ @Slf4j public class PrAnalysisClientImpl implements PrAnalysisClient { - @Value("${dev-oops.github-pr-analysis.prompt}") - private String promptTemplate; - - @Value("${dev-oops.github-pr-analysis.system}") - private String systemPrompt; - private final ChatClient chatClient; + private final OpenAiChatOptions.Builder openAiChatOptionsBuilder; + private final PromptBuilder promptBuilder; @Override - public AnalyzePrResponse analyze(String title, String description, String diff) { - //json schema 추출 - BeanOutputConverter outputConverter = new BeanOutputConverter<>(AnalyzePrResponse.class); - String jsonSchema = outputConverter.getJsonSchema(); - + public AnalyzePrResponse analyze(AnalyzePrRequest request) { //option 설정 - OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder() - .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, jsonSchema)) - .model("gpt-5-nano") - .reasoningEffort("medium") - .temperature(1.0) + OpenAiChatOptions openAiChatOptions = openAiChatOptionsBuilder + .model(request.model()) .build(); - //prompt 만들고 보내기 - String userPrompt = buildPrompt(title, description, diff); - ChatResponse chatresponse = chatClient.prompt() - .options(openAiChatOptions) - .system(systemPrompt) - .user(userPrompt) - .call() - .chatResponse(); - + ChatResponse chatresponse = callChatResponse( + request.title(), + request.description(), + request.codeDifference(), + openAiChatOptions + ); Usage usage = chatresponse.getMetadata().getUsage(); - System.out.println("Prompt tokens: " + usage.getPromptTokens()); - System.out.println("Completion tokens: " + usage.getCompletionTokens()); - System.out.println("Total tokens: " + usage.getTotalTokens()); - - ObjectMapper objectMapper = new ObjectMapper(); - try { - return objectMapper.readValue(chatresponse.getResult().getOutput().getText(), AnalyzePrResponse.class); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - private String buildPrompt(String title, String description, String diff) { - return promptTemplate - .replace("{title}", title) - .replace("{description}", description) - .replace("{diff}", encodeDiff(diff)); + String analysisResult = chatresponse.getResult().getOutput().getText(); + return new AnalyzePrResponse(usage, analysisResult); } - private String encodeDiff(String diff) { - return Base64.getEncoder().encodeToString(diff.getBytes(StandardCharsets.UTF_8)); + 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/dto/request/AnalyzePrRequest.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/request/AnalyzePrRequest.java new file mode 100644 index 0000000..4606262 --- /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 2e30d76..436dac6 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,24 +1,31 @@ package com.devoops.dto.response; - -import com.fasterxml.jackson.annotation.JsonProperty; -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( - @JsonProperty(required = true) String summary, - @JsonProperty(required = true) List summaryDetails, - @JsonProperty(required = true) List questions + int promptTokens, + int completionTokens, + int totalTokens, + PrAnalysis prAnalysis ) { - public record SummaryDetails( - @JsonProperty(required = true) String title, - @JsonProperty(required = true) String description - ) { + + public AnalyzePrResponse(Usage usage, String analysisResult) { + this( + usage.getPromptTokens(), + usage.getCompletionTokens(), + usage.getTotalTokens(), + resolvePrAnalysis(analysisResult) + ); } - public record CategorizedQuestion( - @JsonProperty(required = true) String category, - @JsonProperty(required = true) List question - ) { + private static PrAnalysis resolvePrAnalysis(String analysisResult) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.readValue(analysisResult, PrAnalysis.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } } - 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 0000000..284718c --- /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-mcp-app/src/main/java/com/devoops/adaptor/PrAnalysisAdapter.java b/gss-mcp-app/src/main/java/com/devoops/adaptor/PrAnalysisAdapter.java index 4f40f0a..4e9ba77 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,7 +2,9 @@ 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.dto.response.PrAnalysis; import com.devoops.util.SummaryFormatter; import java.util.List; import java.util.Map; @@ -16,17 +18,22 @@ 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()); + public AdaptedAnalyzePrResponse analyze(String title, String description, String diff, String model) { + AnalyzePrRequest analyzePrRequest = new AnalyzePrRequest(title, description, diff, model); + AnalyzePrResponse analyzePrResponse = prAnalysisClient.analyze(analyzePrRequest); + PrAnalysis prAnalysis = analyzePrResponse.prAnalysis(); + String detailSummary = resolveDetailSummary(prAnalysis.summaryDetails()); return new AdaptedAnalyzePrResponse( - analyzePrResponse.summary(), + analyzePrResponse.promptTokens(), + analyzePrResponse.completionTokens(), + analyzePrResponse.totalTokens(), + prAnalysis.summary(), detailSummary, - analyzePrResponse.questions() + prAnalysis.questions() ); } - private String resolveDetailSummary(List summaryDetails) { + private 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/dto/request/AdaptedAnalyzePrResponse.java b/gss-mcp-app/src/main/java/com/devoops/dto/request/AdaptedAnalyzePrResponse.java index d2ba962..1f377a4 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,9 +1,13 @@ package com.devoops.dto.request; -import com.devoops.dto.response.AnalyzePrResponse.CategorizedQuestion; +import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis.CategorizedQuestion; import java.util.List; public record AdaptedAnalyzePrResponse( + int promptTokens, + int completionTokens, + int totalTokens, String summary, String detailSummary, List questions 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 595375d..98cba35 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 @@ -8,7 +8,7 @@ import com.devoops.domain.entity.github.question.Question; 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.pullrequest.PullRequestService; import com.devoops.service.question.QuestionService; import java.util.List; @@ -34,7 +34,7 @@ public void createQuestion(QuestionCreateEvent questionCreateEvent) { PullRequest readyPullRequest = questionCreateEvent.getInitializedPullRequest(); String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); - AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisAdapter.analyze(request.title(), request.description(), diff); + AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisAdapter.analyze(request.title(), request.description(), diff, "gpt-5-mini"); PullRequest updatedPullRequest = pullRequestService.updateAnalyzeResult( @@ -51,7 +51,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/test/java/com/devoops/PrAnalysisClientImplTest.java b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java index fd1c212..b3cfd9c 100644 --- a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java +++ b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java @@ -1,14 +1,14 @@ package com.devoops; import com.devoops.client.PrAnalysisClientImpl; -import com.devoops.dto.response.AnalyzePrResponse; -import org.junit.jupiter.api.Disabled; +import com.devoops.dto.request.AnalyzePrRequest; +import com.devoops.dto.response.PrAnalysis; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -@Disabled +//@Disabled class PrAnalysisClientImplTest { @Autowired @@ -17,23 +17,30 @@ class PrAnalysisClientImplTest { @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); - + } +// 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); +// + } +// """; + + String title = "feat: MSW 세팅 및 일부 mock api 제작"; + String desc = """ + "\\r\\n\\r\\n## What?\\r\\n\\r\\nclose #94 \\r\\n\\r\\n- MSW(Mock Service Worker)에 대한 세팅을 진행하였습니다.\\r\\n- 모든 api에 대하여 작업을 해두기가 어려워, 테스트를 위해 홈에 들어가는 api를 우선적으로 작업해 두었습니다. \\r\\n(아래는 작업된 mock api 리스트 입니다.)\\r\\n\\r\\n|repositoriesHandler|userHandler|\\r\\n|:---:|:---:|\\r\\n|[/api/repositories/{repositoryId}/pull-requests](https://api.dev-oops.kr/swagger-ui/index.html#/Repository%20API/getRepositoryPullRequests)
[/api/repositories/me](https://api.dev-oops.kr/swagger-ui/index.html#/Repository%20API/getMyRepositories)
[/api/repositories/pull-requests](https://api.dev-oops.kr/swagger-ui/index.html#/Repository%20API/getRepositoryEntirePullRequests)
[/api/repositories/pull-requests/{pullRequestId}](https://api.dev-oops.kr/swagger-ui/index.html#/Pull%20Request%20API/getPullRequest)|[/api/users/me](https://api.dev-oops.kr/swagger-ui/index.html#/User%20API/getMyInfo)|\\r\\n\\r\\n- `.env.local`에 대한 변화가 있어, 노션에 추가해 두었습니다. ([링크](https://www.notion.so/Env-21bbee7f599680ca9d45fc980cf08baa?source=copy_link))\\r\\n\\r\\n## Why?\\r\\n\\r\\n- 백엔드가 먹통이 되니 프론트엔드 개발이 지연되는 문제를 방지하고 싶었습니다.\\r\\n- 또한 차후 백엔드 작업으로 부터 의존성을 제거할 수 있다고 생각해 진행하게 되었습니다.\\r\\n\\r\\n## How?\\r\\n\\r\\n

아.. 이번 일을 왜 시작했을까 싶을정도로 하면서 후회를...

\\r\\n\\r\\n서버가 멀쩡하게 돌아가는게 얼마나 감사한지 깨달았습니다.\\r\\n(MSW를 tanstack, Next app router과 함께 쓰려니 설정하는 부분에 있어서 수많은 문제들이 있었습니다..)\\r\\n\\r\\n### 📌 MSW에 대한 간단한 설명\\r\\n\\r\\nMSW는 크게 다음과 같은 역할을 한다고 생각하시면 됩니다.\\r\\n- 브라우저가 실제 서버로 보내려는 네트워크 요청을 가로챈다.\\r\\n- 개발자가 정의한 핸들러(handler)를 통해, 미리 설정해둔 값을 반환한다.\\r\\n\\r\\n### 📌 mocks 폴더 구조에 대한 설명\\r\\n\\r\\nmocks 폴더는 거의 MSW를 위해 만들어 졌다고 보시면 됩니다. \\r\\n(공식문서에서도 이렇게 작업하라고 되어 있어용.)\\r\\n\\r\\n```\\r\\nmocks/\\r\\n├── handlers/ <- API 요청(내 정보 조회 등)을 어떻게 처리할지 정의하는 폴더\\r\\n│ ├── repositoriesHandler.ts\\r\\n│ └── userHandler.ts\\r\\n├── responses/ <- mock response를 정의 하는 파일 (하위 폴더는 apis 처럼 endpoint를 기준으로 나눔)\\r\\n│ ├── repositories/\\r\\n│ │ ├── getEntirePullRequests.json\\r\\n│ │ ├── getPullRequest.json\\r\\n│ │ ├── getRepositoriesMe.json\\r\\n│ │ └── getRepositoryPullRequests.json\\r\\n│ ├── user/ \\r\\n│ └── getMyInfo.json\\r\\n├── browser.ts <- 브라우저에서 실행하기 위한 파일\\r\\n├── handlers.ts <- 위의 /handlers 폴더에서 정의한 파일을 다 묶은 파일\\r\\n├── index.ts <- 실행에 대한 로직이 들어있는 파일\\r\\n└── server.ts <- 서버에서 돌하가게 하기 위한 파일\\r\\n```\\r\\n\\r\\n### 📌 실행 위해 알아두어야 할 것\\r\\n\\r\\nMSW는 개발을 위한 것이므로 production 환경에서는 돌아갈 필요가 없습니다.\\r\\n그래서 `process.env.NODE_ENV !== \\"production\\"`으로 처리할까 하다가, 사용자가 원할때 MSW를 껏다 켰다 쉽게 하면 좋을 것 같아서 `.env.local`에 `NEXT_PUBLIC_MOCK_API` 값을 통해서 제어하는 방식으로 해 두었습니다. \\r\\n\\r\\n```\\r\\nNEXT_PUBLIC_MOCK_API=\\"enabled\\" // 실행 o\\r\\nNEXT_PUBLIC_MOCK_API=\\"disabled\\" // 실행 x\\r\\n```\\r\\n|\\"스크린샷|\\r\\n|:---:|\\r\\n|정상적으로 실행되면, 다음과 같이 빨간색으로 실행되었다는 문구가 뜹니다.|\\r\\n\\r\\n- 추가1) msw 뜰때는 token과 상관없이 개발이 되어야 한다 생각해서 홈으로 접근해도 landing으로 redirect 되지 않도록 처리 했습니다\\r\\n- 추가2) `/api/repositories/${repositoryId}/pull-requests`과 같이 인자에 따라서 결과가 달라지는 api의 경우 인자의 값이 달라도 동일한 값이 전달되도록 해 두었습니다.\\r\\n\\r\\n### 📌 실행 화면\\r\\n\\r\\n|\\"스크린샷|\\"스크린샷|\\r\\n|:---:|:---:|\\r\\n|처음 실행하면 다음과 같이 뜰수도 있는데 새로고침 하시면 됩니다!|전체와 첫번째 레포의 경우 문제없이 동작|\\r\\n\\r\\n|\\"스크린샷|\\r\\n|:---:|\\r\\n|두번째 레포의 경우 handler에서 404 에러가 발생하도록 해둠|\\r\\n\\r\\n## Check List\\r\\n\\r\\n- [x] Merge 할 브랜치가 올바른가?\\r\\n\\r\\n\\r\\n\\n## Summary by CodeRabbit\\n\\n* **New Features**\\n * Mock API 모드 도입(NEXT_PUBLIC_MOCK_API='enabled'): MSW 기반 클라이언트/서버 초기화와 자동 활성화로 사용자·레포/PR 응답 시뮬레이션 제공.\\n * 여러 모의 핸들러와 JSON 응답 추가로 목록·상세·사용자 정보 뷰 테스트 가능.\\n\\n* **Chores**\\n * msw 개발 의존성 및 루트 설정 추가, 서비스워커 스크립트 타입검사 제외.\\n * 라우트 상수(ROUTES.API) 대거 추가·정리 및 RETROSPECTIVE 파라미터 필수화.\\n\\n* **Style**\\n * ESLint 규칙 추가/경고화로 코드 스타일 강화.\\n\\n* **Behavior**\\n * 미들웨어: Mock 모드일 때 보호 경로 토큰 검사 건너뜀.\\n\\n* **Other**\\n * 애플리케이션 상수(페이지당 항목 수 등) 추가.\\n" """; + String diff = example(); long startTime = System.currentTimeMillis(); - AnalyzePrResponse result = prAnalysisClient.analyze(title, desc, diff); + 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"); @@ -42,4 +49,895 @@ void analyzePr_shouldReturnSummaryAndQuestions() { result.summaryDetails().forEach(q -> System.out.println("- " + q)); result.questions().forEach(q -> System.out.println("- " + q)); } + + private String example() { + return """ + diff --git a/.eslintrc.json b/.eslintrc.json + index e8e37be..bfb69c7 100644 + --- a/.eslintrc.json + +++ b/.eslintrc.json + @@ -42,6 +42,14 @@ + "unnamedComponents": "arrow-function" + } + ], + + "import/no-extraneous-dependencies": [ + + "error", + + { + + "devDependencies": [ + + "**/mocks/**" + + ] + + } + + ], + "react/jsx-curly-brace-presence": ["warn", { "props": "always", "children": "always" }], + "react/jsx-props-no-spreading": "off", + "arrow-body-style": "off", + @@ -62,7 +70,9 @@ + "better-tailwindcss/no-unregistered-classes": "off", + "better-tailwindcss/enforce-consistent-line-wrapping": "off", + "react/button-has-type": "off", + - "@typescript-eslint/no-unused-vars": "error" + + "@typescript-eslint/no-unused-vars": "error", + + "class-methods-use-this": "warn", + + "react/jsx-no-useless-fragment": "warn" + }, + "ignorePatterns": ["node_modules/"] + } + diff --git a/package.json b/package.json + index 1c8aebc..0824817 100644 + --- a/package.json + +++ b/package.json + @@ -55,6 +55,7 @@ + "eslint-plugin-react-hooks": "^5.2.0", + "husky": "^9.1.7", + "lint-staged": "^15.5.2", + + "msw": "^2.10.5", + "prettier": "^3.5.3", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.5", + @@ -74,5 +75,10 @@ + "npm": "please-use-yarn", + "pnpm": "please-use-yarn", + "yarn": "^1.22.x" + + }, + + "msw": { + + "workerDirectory": [ + + "public" + + ] + } + -} + +} + \\ No newline at end of file + diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js + new file mode 100644 + index 0000000..723b071 + --- /dev/null + +++ b/public/mockServiceWorker.js + @@ -0,0 +1,344 @@ + +/* eslint-disable */ + +/* tslint:disable */ + + + +/** + + * Mock Service Worker. + + * @see https://github.com/mswjs/msw + + * - Please do NOT modify this file. + + */ + + + +const PACKAGE_VERSION = '2.10.5' + +const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' + +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') + +const activeClientIds = new Set() + + + +addEventListener('install', function () { + + self.skipWaiting() + +}) + + + +addEventListener('activate', function (event) { + + event.waitUntil(self.clients.claim()) + +}) + + + +addEventListener('message', async function (event) { + + const clientId = Reflect.get(event.source || {}, 'id') + + + + if (!clientId || !self.clients) { + + return + + } + + + + const client = await self.clients.get(clientId) + + + + if (!client) { + + return + + } + + + + const allClients = await self.clients.matchAll({ + + type: 'window', + + }) + + + + switch (event.data) { + + case 'KEEPALIVE_REQUEST': { + + sendToClient(client, { + + type: 'KEEPALIVE_RESPONSE', + + }) + + break + + } + + + + case 'INTEGRITY_CHECK_REQUEST': { + + sendToClient(client, { + + type: 'INTEGRITY_CHECK_RESPONSE', + + payload: { + + packageVersion: PACKAGE_VERSION, + + checksum: INTEGRITY_CHECKSUM, + + }, + + }) + + break + + } + + + + case 'MOCK_ACTIVATE': { + + activeClientIds.add(clientId) + + + + sendToClient(client, { + + type: 'MOCKING_ENABLED', + + payload: { + + client: { + + id: client.id, + + frameType: client.frameType, + + }, + + }, + + }) + + break + + } + + + + case 'MOCK_DEACTIVATE': { + + activeClientIds.delete(clientId) + + break + + } + + + + case 'CLIENT_CLOSED': { + + activeClientIds.delete(clientId) + + + + const remainingClients = allClients.filter((client) => { + + return client.id !== clientId + + }) + + + + // Unregister itself when there are no more clients + + if (remainingClients.length === 0) { + + self.registration.unregister() + + } + + + + break + + } + + } + +}) + + + +addEventListener('fetch', function (event) { + + // Bypass navigation requests. + + if (event.request.mode === 'navigate') { + + return + + } + + + + // Opening the DevTools triggers the "only-if-cached" request + + // that cannot be handled by the worker. Bypass such requests. + + if ( + + event.request.cache === 'only-if-cached' && + + event.request.mode !== 'same-origin' + + ) { + + return + + } + + + + // Bypass all requests when there are no active clients. + + // Prevents the self-unregistered worked from handling requests + + // after it's been deleted (still remains active until the next reload). + + if (activeClientIds.size === 0) { + + return + + } + + + + const requestId = crypto.randomUUID() + + event.respondWith(handleRequest(event, requestId)) + +}) + + + +/** + + * @param {FetchEvent} event + + * @param {string} requestId + + */ + +async function handleRequest(event, requestId) { + + const client = await resolveMainClient(event) + + const requestCloneForEvents = event.request.clone() + + const response = await getResponse(event, client, requestId) + + + + // Send back the response clone for the "response:*" life-cycle events. + + // Ensure MSW is active and ready to handle the message, otherwise + + // this message will pend indefinitely. + + if (client && activeClientIds.has(client.id)) { + + const serializedRequest = await serializeRequest(requestCloneForEvents) + + + + // Clone the response so both the client and the library could consume it. + + const responseClone = response.clone() + + + + sendToClient( + + client, + + { + + type: 'RESPONSE', + + payload: { + + isMockedResponse: IS_MOCKED_RESPONSE in response, + + request: { + + id: requestId, + + ...serializedRequest, + + }, + + response: { + + type: responseClone.type, + + status: responseClone.status, + + statusText: responseClone.statusText, + + headers: Object.fromEntries(responseClone.headers.entries()), + + body: responseClone.body, + + }, + + }, + + }, + + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + + ) + + } + + + + return response + +} + + + +/** + + * Resolve the main client for the given event. + + * Client that issues a request doesn't necessarily equal the client + + * that registered the worker. It's with the latter the worker should + + * communicate with during the response resolving phase. + + * @param {FetchEvent} event + + * @returns {Promise} + + */ + +async function resolveMainClient(event) { + + const client = await self.clients.get(event.clientId) + + + + if (activeClientIds.has(event.clientId)) { + + return client + + } + + + + if (client?.frameType === 'top-level') { + + return client + + } + + + + const allClients = await self.clients.matchAll({ + + type: 'window', + + }) + + + + return allClients + + .filter((client) => { + + // Get only those clients that are currently visible. + + return client.visibilityState === 'visible' + + }) + + .find((client) => { + + // Find the client ID that's recorded in the + + // set of clients that have registered the worker. + + return activeClientIds.has(client.id) + + }) + +} + + + +/** + + * @param {FetchEvent} event + + * @param {Client | undefined} client + + * @param {string} requestId + + * @returns {Promise} + + */ + +async function getResponse(event, client, requestId) { + + // Clone the request because it might've been already used + + // (i.e. its body has been read and sent to the client). + + const requestClone = event.request.clone() + + + + function passthrough() { + + // Cast the request headers to a new Headers instance + + // so the headers can be manipulated with. + + const headers = new Headers(requestClone.headers) + + + + // Remove the "accept" header value that marked this request as passthrough. + + // This prevents request alteration and also keeps it compliant with the + + // user-defined CORS policies. + + const acceptHeader = headers.get('accept') + + if (acceptHeader) { + + const values = acceptHeader.split(',').map((value) => value.trim()) + + const filteredValues = values.filter( + + (value) => value !== 'msw/passthrough', + + ) + + + + if (filteredValues.length > 0) { + + headers.set('accept', filteredValues.join(', ')) + + } else { + + headers.delete('accept') + + } + + } + + + + return fetch(requestClone, { headers }) + + } + + + + // Bypass mocking when the client is not active. + + if (!client) { + + return passthrough() + + } + + + + // Bypass initial page load requests (i.e. static assets). + + // The absence of the immediate/parent client in the map of the active clients + + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + + // and is not ready to handle requests. + + if (!activeClientIds.has(client.id)) { + + return passthrough() + + } + + + + // Notify the client that a request has been intercepted. + + const serializedRequest = await serializeRequest(event.request) + + const clientMessage = await sendToClient( + + client, + + { + + type: 'REQUEST', + + payload: { + + id: requestId, + + ...serializedRequest, + + }, + + }, + + [serializedRequest.body], + + ) + + + + switch (clientMessage.type) { + + case 'MOCK_RESPONSE': { + + return respondWithMock(clientMessage.data) + + } + + + + case 'PASSTHROUGH': { + + return passthrough() + + } + + } + + + + return passthrough() + +} + + + +/** + + * @param {Client} client + + * @param {any} message + + * @param {Array} transferrables + + * @returns {Promise} + + */ + +function sendToClient(client, message, transferrables = []) { + + return new Promise((resolve, reject) => { + + const channel = new MessageChannel() + + + + channel.port1.onmessage = (event) => { + + if (event.data && event.data.error) { + + return reject(event.data.error) + + } + + + + resolve(event.data) + + } + + + + client.postMessage(message, [ + + channel.port2, + + ...transferrables.filter(Boolean), + + ]) + + }) + +} + + + +/** + + * @param {Response} response + + * @returns {Response} + + */ + +function respondWithMock(response) { + + // Setting response status code to 0 is a no-op. + + // However, when responding with a "Response.error()", the produced Response + + // instance will have status code set to 0. Since it's not possible to create + + // a Response instance with status code 0, handle that use-case separately. + + if (response.status === 0) { + + return Response.error() + + } + + + + const mockedResponse = new Response(response.body, response) + + + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + + value: true, + + enumerable: true, + + }) + + + + return mockedResponse + +} + + + +/** + + * @param {Request} request + + */ + +async function serializeRequest(request) { + + return { + + url: request.url, + + mode: request.mode, + + method: request.method, + + headers: Object.fromEntries(request.headers.entries()), + + cache: request.cache, + + credentials: request.credentials, + + destination: request.destination, + + integrity: request.integrity, + + redirect: request.redirect, + + referrer: request.referrer, + + referrerPolicy: request.referrerPolicy, + + body: await request.arrayBuffer(), + + keepalive: request.keepalive, + + } + +} + diff --git a/src/apis/github/github.api.ts b/src/apis/github/github.api.ts + index 5e4d072..81c8cba 100644 + --- a/src/apis/github/github.api.ts + +++ b/src/apis/github/github.api.ts + @@ -1,5 +1,3 @@ + -/* eslint-disable class-methods-use-this */ + - + export const GITHUB_API_URL = 'https://github.com'; + \s + class GithubApi { + diff --git a/src/app/layout.tsx b/src/app/layout.tsx + index 6190826..b92d475 100644 + --- a/src/app/layout.tsx + +++ b/src/app/layout.tsx + @@ -2,7 +2,9 @@ import type { Metadata } from 'next'; + import { ReactNode } from 'react'; + \s + import GASuspense from '@/components/common/GA/GASuspense'; + +import { initMSW } from '@/mocks'; + import ModalProvider from '@/providers/ModalContext'; + +import MSWClientProvider from '@/providers/MSWClientProvider'; + import QueryProvider from '@/providers/QueryProvider'; + \s + import './globals.css'; + @@ -26,10 +28,15 @@ export default function RootLayout({ + }: Readonly<{ + children: ReactNode; + }>) { + + if (process.env.NEXT_PUBLIC_MOCK_API === 'enabled') { + + initMSW(); + + } + + + return ( + + + + + {process.env.NEXT_PUBLIC_MOCK_API === 'enabled' ? : null} + {children} +
+ + diff --git a/src/constants/domain.ts b/src/constants/domain.ts + new file mode 100644 + index 0000000..74f1236 + --- /dev/null + +++ b/src/constants/domain.ts + @@ -0,0 +1,2 @@ + +export const ITEMS_PER_PAGE = 5; + +export const NO_PR_IN_REPOSITORY_ID = 20010903; + diff --git a/src/constants/routes.ts b/src/constants/routes.ts + index aff85dc..249683f 100644 + --- a/src/constants/routes.ts + +++ b/src/constants/routes.ts + @@ -4,6 +4,42 @@ export const ROUTES = { + LANDING: '/landing', + AUTH_GITHUB: '/auth/github', + REPOLINK: '/repolink', + - RETROSPECTIVE: (prId: number | undefined) => `/retrospective/${prId}`, + + RETROSPECTIVE: (pullRequestId: number) => `/retrospective/${pullRequestId}`, + + }, + + API: { + + // Auth API + + LOGOUT_V1: '/api/v1/auth/logout', + + REISSUE_TOKEN_V1: '/api/v1/auth/github/refresh', + + LOGOUT: '/api/auth/logout', + + ISSUE_TOKEN: '/api/auth/github', + + REISSUE_TOKEN: '/api/auth/github/refresh', + + + + // Question API + + UPDATE_ALL_ANSWERS: '/api/questions/answer', + + CREATE_ANSWER: (questionId: number | string) => `/api/questions/${questionId}/answer`, + + DELETE_ANSWER: (answerId: number | string) => `/api/questions/answer/${answerId}`, + + UPDATE_ANSWER: (answerId: number | string) => `/api/questions/answer/${answerId}`, + + + + // GitHub API + + REGISTER_WEBHOOK: (repositoryId: number | string) => `/api/v1/github/repositories/${repositoryId}/webhooks`, + + + + // Repository API + + SAVE_REPOSITORY: '/api/repositories', + + REPOSITORY_PULL_REQUESTS: (repositoryId: number | string) => `/api/repositories/${repositoryId}/pull-requests`, + + ENTIRE_PULL_REQUESTS: '/api/repositories/pull-requests', + + MY_REPOSITORIES: '/api/repositories/me', + + DELETE_REPOSITORY: (repositoryId: number | string) => `/api/repositories/${repositoryId}`, + + + + // Pull Request API + + UPDATE_PULL_REQUEST_TO_DONE: (pullRequestId: number | string) => `/api/pull-requests/${pullRequestId}/done`, + + GET_PULL_REQUEST: (pullRequestId: number | string) => `/api/repositories/pull-requests/${pullRequestId}`, + + GET_DETAIL_PULL_REQUEST: (pullRequestId: number | string) => `/api/pull-requests/${pullRequestId}`, + + GET_PULL_REQUEST_RANKING: '/api/pull-requests/ranking', + + + + // User API + + GET_MY_INFO: '/api/users/me', + + + + // Ping API + + PING: '/api/ping', + }, + } as const; + diff --git a/src/middleware.ts b/src/middleware.ts + index 64b5536..a793066 100644 + --- a/src/middleware.ts + +++ b/src/middleware.ts + @@ -19,7 +19,7 @@ export async function middleware(req: NextRequest) { + return pathname === pattern || pathname.startsWith(pattern); + }); + \s + - if (isProtected) { + + if (process.env.NEXT_PUBLIC_MOCK_API !== 'enabled' && isProtected) { + const token = req.cookies.get(TOKEN_KEY)?.value; + \s + if (!token) { + diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts + new file mode 100644 + index 0000000..c680017 + --- /dev/null + +++ b/src/mocks/browser.ts + @@ -0,0 +1,5 @@ + +import { setupWorker } from 'msw/browser'; + + + +import handlers from '@/mocks/handlers'; + + + +export const worker = setupWorker(...handlers); + diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts + new file mode 100644 + index 0000000..c76fa82 + --- /dev/null + +++ b/src/mocks/handlers.ts + @@ -0,0 +1,8 @@ + +import { type HttpHandler } from 'msw'; + + + +import repositoriesHandler from '@/mocks/handlers/repositoriesHandler'; + +import userHandler from '@/mocks/handlers/userHandler'; + + + +const handlers: HttpHandler[] = [...userHandler, ...repositoriesHandler]; + + + +export default handlers; + diff --git a/src/mocks/handlers/repositoriesHandler.ts b/src/mocks/handlers/repositoriesHandler.ts + new file mode 100644 + index 0000000..ce04b49 + --- /dev/null + +++ b/src/mocks/handlers/repositoriesHandler.ts + @@ -0,0 +1,34 @@ + +import { HttpResponse, http } from 'msw'; + + + +import { NO_PR_IN_REPOSITORY_ID } from '@/constants/domain'; + +import { ROUTES } from '@/constants/routes'; + +import getEntirePullRequests from '@/mocks/responses/repositories/getEntirePullRequests.json'; + +import getPullRequest from '@/mocks/responses/repositories/getPullRequest.json'; + +import getRepositoriesMe from '@/mocks/responses/repositories/getRepositoriesMe.json'; + +import getRepositoryPullRequests from '@/mocks/responses/repositories/getRepositoryPullRequests.json'; + + + +const repositoriesHandler = [ + + http.get(`*${ROUTES.API.MY_REPOSITORIES}`, () => { + + return HttpResponse.json(getRepositoriesMe, { status: 200 }); + + }), + + + + http.get(`*${ROUTES.API.REPOSITORY_PULL_REQUESTS(':repositoryId')}`, ({ params }) => { + + const { repositoryId } = params; + + + + if (Number(repositoryId) === NO_PR_IN_REPOSITORY_ID) { + + return new HttpResponse(null, { status: 404 }); + + } + + + + return HttpResponse.json(getRepositoryPullRequests, { status: 200 }); + + }), + + + + http.get(`*${ROUTES.API.ENTIRE_PULL_REQUESTS}`, () => { + + return HttpResponse.json(getEntirePullRequests, { status: 200 }); + + }), + + + + http.get(`*${ROUTES.API.GET_PULL_REQUEST(':pullRequestId')}`, () => { + + return HttpResponse.json(getPullRequest, { status: 200 }); + + }), + +]; + + + +export default repositoriesHandler; + diff --git a/src/mocks/handlers/userHandler.ts b/src/mocks/handlers/userHandler.ts + new file mode 100644 + index 0000000..32ceb20 + --- /dev/null + +++ b/src/mocks/handlers/userHandler.ts + @@ -0,0 +1,12 @@ + +import { HttpResponse, http } from 'msw'; + + + +import { ROUTES } from '@/constants/routes'; + +import getMyInfo from '@/mocks/responses/user/getMyInfo.json'; + + + +const userHandler = [ + + http.get(`*${ROUTES.API.GET_MY_INFO}`, () => { + + return HttpResponse.json(getMyInfo, { status: 200 }); + + }), + +]; + + + +export default userHandler; + diff --git a/src/mocks/index.ts b/src/mocks/index.ts + new file mode 100644 + index 0000000..aa24ca6 + --- /dev/null + +++ b/src/mocks/index.ts + @@ -0,0 +1,9 @@ + +export async function initMSW() { + + if (typeof window === 'undefined') { + + const { server } = await import('./server'); + + server.listen(); + + } else { + + const { worker } = await import('./browser'); + + worker.start(); + + } + +} + diff --git a/src/mocks/responses/repositories/getEntirePullRequests.json b/src/mocks/responses/repositories/getEntirePullRequests.json + new file mode 100644 + index 0000000..a4c3e75 + --- /dev/null + +++ b/src/mocks/responses/repositories/getEntirePullRequests.json + @@ -0,0 +1,44 @@ + +{ + + "pullRequests": [ + + { + + "id": 100, + + "title": "[FIX] 서버 장애 대응", + + "recordStatus": "PENDING", + + "mergedAt": "2025-05-01", + + "summary": "이번 장애에서의 문제 상황과 대응 과정을 정리하였습니다.", + + "tag": "feat" + + }, + + { + + "id": 101, + + "title": "[ADD] CI/CD 파이프라인 구축", + + "recordStatus": "PROGRESS", + + "mergedAt": "2025-05-02", + + "summary": "GitHub Actions를 활용한 CI/CD 파이프라인을 구축하여 자동 배포 시스템을 마련했습니다.", + + "tag": "chore" + + }, + + { + + "id": 102, + + "title": "[FEAT] 사용자 프로필 페이지 구현", + + "recordStatus": "PENDING", + + "mergedAt": "2025-05-03", + + "summary": "사용자가 자신의 정보를 확인하고 수정할 수 있는 프로필 페이지를 개발 중입니다.", + + "tag": "feat" + + }, + + { + + "id": 103, + + "title": "[REFACTOR] 인증 로직 개선", + + "recordStatus": "PROGRESS", + + "mergedAt": "2025-05-04", + + "summary": "기존 인증 로직의 가독성과 유지보수성을 향상시키기 위해 코드를 리팩토링했습니다.", + + "tag": "refactor" + + }, + + { + + "id": 104, + + "title": "[DOCS] API 문서 업데이트", + + "recordStatus": "DONE", + + "mergedAt": "2025-05-05", + + "summary": "최신 API 변경사항을 반영하여 개발자 문서를 업데이트했습니다.", + + "tag": "docs" + + } + + ] + +} + diff --git a/src/mocks/responses/repositories/getPullRequest.json b/src/mocks/responses/repositories/getPullRequest.json + new file mode 100644 + index 0000000..99c8496 + --- /dev/null + +++ b/src/mocks/responses/repositories/getPullRequest.json + @@ -0,0 +1,60 @@ + +{ + + "id": 100, + + "title": "[FIX] 서버 장애 대응", + + "recordStatus": "PENDING", + + "mergedAt": "2025-05-01", + + "summary": "이번 장애에서의 문제 상황과 대응 과정을 정리하였습니다.", + + "tag": "feat", + + "pullRequestUrl": "/", + + "categories": ["성능", "가독성", "테스트"], + + "questions": [ + + { + + "id": 200, + + "isSelected": true, + + "category": "성능", + + "content": "성능적으로 좋은 선택이라 생각하나요?", + + "createdAt": "2025-06-24T15:29:45Z", + + "updatedAt": "2025-06-24T15:29:45Z" + + }, + + { + + "id": 201, + + "isSelected": false, + + "category": "가독성", + + "content": "이 코드는 다른 사람이 쉽게 이해할 수 있나요?", + + "createdAt": "2025-06-25T10:00:00Z", + + "updatedAt": "2025-06-25T10:00:00Z" + + }, + + { + + "id": 202, + + "isSelected": false, + + "category": "테스트", + + "content": "작성한 테스트 코드가 충분한가요?", + + "createdAt": "2025-06-26T11:30:00Z", + + "updatedAt": "2025-06-26T11:30:00Z" + + }, + + { + + "id": 203, + + "isSelected": true, + + "category": "성능", + + "content": "성능적으로 좋은 선택이라 생각하나요? 22", + + "createdAt": "2025-06-24T15:29:45Z", + + "updatedAt": "2025-06-24T15:29:45Z" + + }, + + { + + "id": 204, + + "isSelected": false, + + "category": "가독성", + + "content": "이 코드는 다른 사람이 쉽게 이해할 수 있나요? 22", + + "createdAt": "2025-06-25T10:00:00Z", + + "updatedAt": "2025-06-25T10:00:00Z" + + }, + + { + + "id": 205, + + "isSelected": false, + + "category": "테스트", + + "content": "작성한 테스트 코드가 충분한가요? 22", + + "createdAt": "2025-06-26T11:30:00Z", + + "updatedAt": "2025-06-26T11:30:00Z" + + } + + ] + +} + diff --git a/src/mocks/responses/repositories/getRepositoriesMe.json b/src/mocks/responses/repositories/getRepositoriesMe.json + new file mode 100644 + index 0000000..03fd71c + --- /dev/null + +++ b/src/mocks/responses/repositories/getRepositoriesMe.json + @@ -0,0 +1,14 @@ + +{ + + "repositories": [ + + { + + "id": 1, + + "name": "첫번째 레포", + + "pullRequestCount": 5 + + }, + + { + + "id": 20010903, + + "name": "두번째 레포", + + "pullRequestCount": 3 + + } + + ] + +} + diff --git a/src/mocks/responses/repositories/getRepositoryPullRequests.json b/src/mocks/responses/repositories/getRepositoryPullRequests.json + new file mode 100644 + index 0000000..9da3e9e + --- /dev/null + +++ b/src/mocks/responses/repositories/getRepositoryPullRequests.json + @@ -0,0 +1,44 @@ + +{ + + "pullRequests": [ + + { + + "id": 301, + + "title": "[FEAT] 다크 모드 지원 추가", + + "recordStatus": "DONE", + + "mergedAt": "2025-06-10", + + "summary": "사용자 경험 향상을 위해 애플리케이션 전체에 다크 모드 테마를 적용했습니다.", + + "tag": "feat" + + }, + + { + + "id": 302, + + "title": "[FIX] 로그인 시 간헐적 500 에러 수정", + + "recordStatus": "PROGRESS", + + "mergedAt": "2025-06-11", + + "summary": "특정 조건에서 로그인 요청 시 발생하던 서버 내부 오류를 해결하고 있습니다.", + + "tag": "fix" + + }, + + { + + "id": 303, + + "title": "[CHORE] 의존성 라이브러리 버전 업데이트", + + "recordStatus": "DONE", + + "mergedAt": "2025-06-12", + + "summary": "보안 및 성능 개선을 위해 주요 npm 패키지들을 최신 버전으로 업데이트했습니다.", + + "tag": "chore" + + }, + + { + + "id": 304, + + "title": "[REFACTOR] 전역 상태 관리 로직 개선", + + "recordStatus": "PENDING", + + "mergedAt": "2025-06-13", + + "summary": "기존 Context API 기반 상태 관리를 Recoil로 마이그레이션하여 성능을 최적화합니다.", + + "tag": "refactor" + + }, + + { + + "id": 305, + + "title": "[TEST] E2E 테스트 케이스 추가", + + "recordStatus": "DONE", + + "mergedAt": "2025-06-14", + + "summary": "주요 사용자 플로우에 대한 Cypress E2E 테스트 코드를 추가하여 안정성을 높였습니다.", + + "tag": "test" + + } + + ] + +} + diff --git a/src/mocks/responses/user/getMyInfo.json b/src/mocks/responses/user/getMyInfo.json + new file mode 100644 + index 0000000..ec5535e + --- /dev/null + +++ b/src/mocks/responses/user/getMyInfo.json + @@ -0,0 +1,5 @@ + +{ + + "id": 1, + + "nickname": "김유저", + + "profileImageUrl": "https://avatars.githubusercontent.com/u/96560039?s=80&v=4" + +} + diff --git a/src/mocks/server.ts b/src/mocks/server.ts + new file mode 100644 + index 0000000..c4db1a8 + --- /dev/null + +++ b/src/mocks/server.ts + @@ -0,0 +1,5 @@ + +import { setupServer } from 'msw/node'; + + + +import handlers from '@/mocks/handlers'; + + + +export const server = setupServer(...handlers); + diff --git a/src/providers/MSWClientProvider.tsx b/src/providers/MSWClientProvider.tsx + new file mode 100644 + index 0000000..db3994f + --- /dev/null + +++ b/src/providers/MSWClientProvider.tsx + @@ -0,0 +1,13 @@ + +'use client'; + + + +import { useEffect } from 'react'; + + + +import { initMSW } from '@/mocks'; + + + +export default function MSWClientProvider() { + + useEffect(() => { + + initMSW(); + + }, []); + + + + return null; + +} + diff --git a/tsconfig.json b/tsconfig.json + index 77f7e72..a89ba4b 100644 + --- a/tsconfig.json + +++ b/tsconfig.json + @@ -23,5 +23,5 @@ + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "@types/*.d.ts"], + - "exclude": ["node_modules"] + + "exclude": ["node_modules", "public/mockServiceWorker.js"] + } + diff --git a/yarn.lock b/yarn.lock + index f8d7da3..b1226e5 100644 + --- a/yarn.lock + +++ b/yarn.lock + @@ -5411,6 +5411,30 @@ ms@^2.1.1, ms@^2.1.3: + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + \s + +msw@^2.10.5: + + version "2.10.5" + + resolved "https://registry.yarnpkg.com/msw/-/msw-2.10.5.tgz#3e43f12e97581c260bf38d8817732b9fec3bfdb0" + + integrity sha512-0EsQCrCI1HbhpBWd89DvmxY6plmvrM96b0sCIztnvcNHQbXn5vqwm1KlXslo6u4wN9LFGLC1WFjjgljcQhe40A== + + dependencies: + + "@bundled-es-modules/cookie" "^2.0.1" + + "@bundled-es-modules/statuses" "^1.0.1" + + "@bundled-es-modules/tough-cookie" "^0.1.6" + + "@inquirer/confirm" "^5.0.0" + + "@mswjs/interceptors" "^0.39.1" + + "@open-draft/deferred-promise" "^2.2.0" + + "@open-draft/until" "^2.1.0" + + "@types/cookie" "^0.6.0" + + "@types/statuses" "^2.0.4" + + graphql "^16.8.1" + + headers-polyfill "^4.0.2" + + is-node-process "^1.2.0" + + outvariant "^1.4.3" + + path-to-regexp "^6.3.0" + + picocolors "^1.1.1" + + strict-event-emitter "^0.5.1" + + type-fest "^4.26.1" + + yargs "^17.7.2" + + + msw@^2.7.1: + version "2.10.3" + resolved "https://registry.yarnpkg.com/msw/-/msw-2.10.3.tgz#accd0925d2852e9aaa2c86d4fdd724288fee5f35" + + """; + } } 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 d164db7..b6b04f6 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 + ); } } From e29dd6f3ac255d7abfda47dca1b3776dfcf33353 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 00:54:03 +0900 Subject: [PATCH 10/33] =?UTF-8?q?feat:=20event=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20AiModel=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/devoops/event/QuestionCreateEvent.java | 5 ++++- .../main/java/com/devoops/event/QuestionEventListener.java | 4 ++-- .../com/devoops/service/webhook/WebhookFacadeService.java | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) 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 b64c92f..beb7782 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 @@ -12,17 +12,20 @@ public class QuestionCreateEvent extends ApplicationEvent { private final AppWebhookEventRequest request; private final PullRequest initializedPullRequest; private final GithubToken token; + private final String aiModel; public QuestionCreateEvent( Object source, AppWebhookEventRequest request, PullRequest initializedPullRequest, - GithubToken token + GithubToken token, + String aiModel ) { super(source); this.request = request; this.initializedPullRequest = initializedPullRequest; this.token = token; + this.aiModel = aiModel; } } 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 98cba35..d380270 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 @@ -32,10 +32,10 @@ public void createQuestion(QuestionCreateEvent questionCreateEvent) { AppWebhookEventRequest request = questionCreateEvent.getRequest(); GithubToken githubToken = questionCreateEvent.getToken(); PullRequest readyPullRequest = questionCreateEvent.getInitializedPullRequest(); + String aiModel = questionCreateEvent.getAiModel(); String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); - AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisAdapter.analyze(request.title(), request.description(), diff, "gpt-5-mini"); - + AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisAdapter.analyze(request.title(), request.description(), diff, aiModel); PullRequest updatedPullRequest = pullRequestService.updateAnalyzeResult( readyPullRequest.getId(), 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 fe1dbe5..84d7b8b 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 @@ -41,7 +41,7 @@ public void createQuestionWithWebhookEvent(AppWebhookEventRequest request) { triggerUser.getId(), request ); - QuestionCreateEvent questionCreateEvent = new QuestionCreateEvent(this, request, readyPullRequest, triggerUser.getGithubToken()); + QuestionCreateEvent questionCreateEvent = new QuestionCreateEvent(this, request, readyPullRequest, triggerUser.getGithubToken(), "gpt-5-mini"); eventPublisher.publishEvent(questionCreateEvent); } } From fa9bd5142c550ef48080f2842e87a8648a7ced2b Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 01:17:27 +0900 Subject: [PATCH 11/33] =?UTF-8?q?feat:=20PrAnalysisService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=84=EC=84=9D=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devoops/adaptor/PrAnalysisAdapter.java | 22 +------------------ .../dto/request/AdaptedAnalyzePrResponse.java | 20 +++++++++++++++++ .../devoops/event/QuestionCreateEvent.java | 6 +---- .../devoops/event/QuestionEventListener.java | 12 ++++------ .../service/pranalysis/PrAnalysisService.java | 22 +++++++++++++++++++ .../service/webhook/WebhookFacadeService.java | 2 +- 6 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisService.java 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 4e9ba77..a2a612a 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 @@ -4,11 +4,6 @@ import com.devoops.dto.request.AdaptedAnalyzePrResponse; import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; -import com.devoops.dto.response.PrAnalysis; -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; @@ -21,21 +16,6 @@ public class PrAnalysisAdapter { public AdaptedAnalyzePrResponse analyze(String title, String description, String diff, String model) { AnalyzePrRequest analyzePrRequest = new AnalyzePrRequest(title, description, diff, model); AnalyzePrResponse analyzePrResponse = prAnalysisClient.analyze(analyzePrRequest); - PrAnalysis prAnalysis = analyzePrResponse.prAnalysis(); - String detailSummary = resolveDetailSummary(prAnalysis.summaryDetails()); - return new AdaptedAnalyzePrResponse( - analyzePrResponse.promptTokens(), - analyzePrResponse.completionTokens(), - analyzePrResponse.totalTokens(), - prAnalysis.summary(), - detailSummary, - prAnalysis.questions() - ); - } - - private String resolveDetailSummary(List summaryDetails) { - return summaryDetails.stream() - .map(sd -> Map.entry(sd.title(), sd.description())) - .collect(Collectors.collectingAndThen(Collectors.toList(), SummaryFormatter::formatWithNumbering)); + 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 1f377a4..8d4356e 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,8 +1,12 @@ package com.devoops.dto.request; 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, @@ -13,4 +17,20 @@ public record AdaptedAnalyzePrResponse( 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 beb7782..c79faff 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 @@ -12,20 +12,16 @@ public class QuestionCreateEvent extends ApplicationEvent { private final AppWebhookEventRequest request; private final PullRequest initializedPullRequest; private final GithubToken token; - private final String aiModel; - public QuestionCreateEvent( Object source, AppWebhookEventRequest request, PullRequest initializedPullRequest, - GithubToken token, - String aiModel + GithubToken token ) { super(source); this.request = request; this.initializedPullRequest = initializedPullRequest; this.token = token; - this.aiModel = aiModel; } } 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 d380270..d87b1b4 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.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) @@ -32,10 +30,8 @@ public void createQuestion(QuestionCreateEvent questionCreateEvent) { AppWebhookEventRequest request = questionCreateEvent.getRequest(); GithubToken githubToken = questionCreateEvent.getToken(); PullRequest readyPullRequest = questionCreateEvent.getInitializedPullRequest(); - String aiModel = questionCreateEvent.getAiModel(); - String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); - AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisAdapter.analyze(request.title(), request.description(), diff, aiModel); + AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisService.analyzePullRequest(request, githubToken); PullRequest updatedPullRequest = pullRequestService.updateAnalyzeResult( readyPullRequest.getId(), 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 0000000..814e090 --- /dev/null +++ b/gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisService.java @@ -0,0 +1,22 @@ +package com.devoops.service.pranalysis; + +import com.devoops.adaptor.GithubAdaptor; +import com.devoops.adaptor.PrAnalysisAdapter; +import com.devoops.domain.entity.github.token.GithubToken; +import com.devoops.dto.AppWebhookEventRequest; +import com.devoops.dto.request.AdaptedAnalyzePrResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PrAnalysisService { + + private final GithubAdaptor githubAdaptor; + private final PrAnalysisAdapter prAnalysisAdapter; + + public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) { + String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); + return prAnalysisAdapter.analyze(request.title(), request.description(), diff, "gpt-5"); + } +} 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 84d7b8b..fe1dbe5 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 @@ -41,7 +41,7 @@ public void createQuestionWithWebhookEvent(AppWebhookEventRequest request) { triggerUser.getId(), request ); - QuestionCreateEvent questionCreateEvent = new QuestionCreateEvent(this, request, readyPullRequest, triggerUser.getGithubToken(), "gpt-5-mini"); + QuestionCreateEvent questionCreateEvent = new QuestionCreateEvent(this, request, readyPullRequest, triggerUser.getGithubToken()); eventPublisher.publishEvent(questionCreateEvent); } } From 0f3bec9e9d705b061c4b9c4d1afd2736f71d954c Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 01:27:31 +0900 Subject: [PATCH 12/33] =?UTF-8?q?feat:=20model=20=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=B2=A8=EB=9F=B0=EC=8B=B1=20=EA=B8=B0=EC=A4=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/entity/analysis/OpenAiModel.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 gss-domain/src/main/java/com/devoops/domain/entity/analysis/OpenAiModel.java 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 0000000..25f7a56 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/OpenAiModel.java @@ -0,0 +1,28 @@ +package com.devoops.domain.entity.analysis; + +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, 10000, "gpt-5-mini",0.00000025,0.000002), + GPT_5_NANO(10001, 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 OpenAiModel getModelByUsage(int currentUsageWon) { + return Stream.of(values()) + .filter(model -> model.moneyUnderCriteria<=currentUsageWon && model.moneyUpperCriteria>=currentUsageWon) + .findAny() + .orElse(GPT_5_NANO); + } +} From 984a8fa5ee3a8ddc19a48836ed128b085fe80a2d Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 01:33:54 +0900 Subject: [PATCH 13/33] =?UTF-8?q?feat:=20=ED=99=98=EC=9C=A8=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=9C=A0=ED=8B=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/devoops/util/CurrencyUtil.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 gss-common/src/main/java/com/devoops/util/CurrencyUtil.java 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 0000000..6560fa7 --- /dev/null +++ b/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java @@ -0,0 +1,20 @@ +package com.devoops.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CurrencyUtil { + + // 25-08-21 기준 + private static final double CURRENCY_RATE = 1397.84; + + /** + * 달러를 원으로 변환 + * @param usd 달러 금액 + * @return KRW 금액 + */ + public static double usdToKrw(double usd) { + return usd * CURRENCY_RATE; + } +} From a118668cb2d45eaf1cd435b292d8ab6322d0d136 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 01:37:00 +0900 Subject: [PATCH 14/33] =?UTF-8?q?feat:=20=EB=B9=84=EC=9A=A9=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devoops/domain/entity/analysis/OpenAiModel.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 25f7a56..7c9ea13 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 @@ -1,5 +1,6 @@ package com.devoops.domain.entity.analysis; +import com.devoops.util.CurrencyUtil; import java.util.stream.Stream; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -19,10 +20,16 @@ public enum OpenAiModel { private final double inputTokenCharge; //달러 private final double outputTokenCharge; //달러 - public OpenAiModel getModelByUsage(int currentUsageWon) { + public static OpenAiModel getModelByUsage(int currentUsageWon) { return Stream.of(values()) .filter(model -> model.moneyUnderCriteria<=currentUsageWon && model.moneyUpperCriteria>=currentUsageWon) .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; + } } From 784aa28a95ffcb04e4cf853552eca82c8b5cf617 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 01:40:05 +0900 Subject: [PATCH 15/33] =?UTF-8?q?feat:=20=EB=B9=84=EC=9A=A9=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devoops/domain/entity/analysis/AiCharge.java | 14 ++++++++++++++ .../repository/analysis/AiChargeRepository.java | 10 ++++++++++ 2 files changed, 24 insertions(+) create mode 100644 gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiCharge.java create mode 100644 gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java 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 0000000..205f221 --- /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.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AiCharge { + + private final int month; + private final double charge; +} 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 0000000..f25df32 --- /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 getByMonth(int month); + + AiCharge addAiCharge(double charge); +} From c889cf49d1591891450b3b387f2e880bb782fbb8 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 01:53:37 +0900 Subject: [PATCH 16/33] =?UTF-8?q?feat:=20=EC=9A=94=EA=B8=88=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20model=20=EC=84=A0=ED=83=9D=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/entity/analysis/OpenAiModel.java | 7 ++++--- .../analysis/AiChargeRepository.java | 2 +- .../service/pranalysis/PrAnalysisService.java | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 5 deletions(-) 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 7c9ea13..f0b6469 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 @@ -10,7 +10,7 @@ public enum OpenAiModel { GPT_5(0, 7500, "gpt-5", 0.00000125, 0.00001), - GPT_5_MINI(7501, 10000, "gpt-5-mini",0.00000025,0.000002), + GPT_5_MINI(7501, 10000, "gpt-5-mini", 0.00000025, 0.000002), GPT_5_NANO(10001, 15000, "gpt-5-nano", 0.00000005, 0.0000004), ; @@ -20,9 +20,10 @@ public enum OpenAiModel { private final double inputTokenCharge; //달러 private final double outputTokenCharge; //달러 - public static OpenAiModel getModelByUsage(int currentUsageWon) { + public static OpenAiModel getModelByUsage(double currentUsageWon) { return Stream.of(values()) - .filter(model -> model.moneyUnderCriteria<=currentUsageWon && model.moneyUpperCriteria>=currentUsageWon) + .filter(model -> model.moneyUnderCriteria <= currentUsageWon + && model.moneyUpperCriteria >= currentUsageWon) .findAny() .orElse(GPT_5_NANO); } 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 index f25df32..3906d84 100644 --- 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 @@ -6,5 +6,5 @@ public interface AiChargeRepository { AiCharge getByMonth(int month); - AiCharge addAiCharge(double charge); + AiCharge addCharge(int month, double charge); } 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 814e090..d66ae34 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 @@ -2,9 +2,13 @@ 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 lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,9 +18,22 @@ public class PrAnalysisService { private final GithubAdaptor githubAdaptor; private final PrAnalysisAdapter prAnalysisAdapter; + private AiChargeRepository chargeRepository; public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) { String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); - return prAnalysisAdapter.analyze(request.title(), request.description(), diff, "gpt-5"); + int month = LocalDate.now().getMonthValue(); + AiCharge aiCharge = chargeRepository.getByMonth(month); + 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(month, consumedCharge); + return result; } } From eb88a59bdbcb6fbd6ddb486acaa2858b9497bf0c Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 02:06:08 +0900 Subject: [PATCH 17/33] =?UTF-8?q?feat:=20=EC=9A=94=EA=B8=88=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=A0=90=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/devoops/domain/entity/analysis/OpenAiModel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f0b6469..f8ed033 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 @@ -10,8 +10,8 @@ public enum OpenAiModel { GPT_5(0, 7500, "gpt-5", 0.00000125, 0.00001), - GPT_5_MINI(7501, 10000, "gpt-5-mini", 0.00000025, 0.000002), - GPT_5_NANO(10001, 15000, "gpt-5-nano", 0.00000005, 0.0000004), + 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; //원 From e8d5d7078823c208ccdd0da0f87eed722b1b8b91 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 02:31:23 +0900 Subject: [PATCH 18/33] =?UTF-8?q?feat:=20=EC=9A=94=EA=B8=88=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/entity/analysis/AiCharge.java | 3 +- .../analysis/AiChargeRepository.java | 2 +- .../jpa/entity/analysis/AiChargeEntity.java | 52 +++++++++++++++++++ .../analysis/AiChargeJpaRepository.java | 22 ++++++++ .../analysis/AiChargeRepositoryImpl.java | 34 ++++++++++++ 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 gss-domain/src/main/java/com/devoops/jpa/entity/analysis/AiChargeEntity.java create mode 100644 gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeJpaRepository.java create mode 100644 gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java 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 index 205f221..5a68584 100644 --- 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 @@ -1,7 +1,5 @@ package com.devoops.domain.entity.analysis; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -9,6 +7,7 @@ @RequiredArgsConstructor public class AiCharge { + private final int year; private final int month; private final double charge; } 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 index 3906d84..1e3b9de 100644 --- 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 @@ -6,5 +6,5 @@ public interface AiChargeRepository { AiCharge getByMonth(int month); - AiCharge addCharge(int month, double charge); + void addCharge(long id, double charge); } 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 0000000..73434e1 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/jpa/entity/analysis/AiChargeEntity.java @@ -0,0 +1,52 @@ +package com.devoops.jpa.entity.analysis; + +import com.devoops.domain.entity.analysis.AiCharge; +import com.devoops.domain.entity.github.answer.Answer; +import com.devoops.jpa.entity.github.answer.AnswerEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "ai_charge") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AiChargeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private int year; + + private int month; + + private double 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/repository/analysis/AiChargeJpaRepository.java b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeJpaRepository.java new file mode 100644 index 0000000..b0cb642 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeJpaRepository.java @@ -0,0 +1,22 @@ +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.id = :id and ai_charge.charge >= 0 + """) + void updateChargeById(@Param("id") long id, @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 0000000..eb510c5 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java @@ -0,0 +1,34 @@ +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.time.LocalDateTime; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AiChargeRepositoryImpl implements AiChargeRepository { + + private final AiChargeJpaRepository chargeJpaRepository; + + @Override + public AiCharge getByMonth(int month) { + ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); + LocalDateTime now = LocalDateTime.now(seoulZoneId); + int todayYear = now.getYear(); + int todayMonth = now.getMonthValue(); + return chargeJpaRepository.findByYearAndMonth(todayYear, todayMonth) + .orElseGet(() -> { + AiCharge initializeCharge = new AiCharge(todayYear, todayMonth, 0); + return chargeJpaRepository.save(AiChargeEntity.from(initializeCharge)); + }).toDomainEntity(); + } + + @Override + public void addCharge(long id, double charge) { + chargeJpaRepository.updateChargeById(id, charge); + } +} From f5e495b310b69d99e4180925f4281e857b7437aa Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 02:34:07 +0900 Subject: [PATCH 19/33] =?UTF-8?q?refactor:=20=EC=9A=94=EA=B8=88=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/analysis/AiChargeRepository.java | 2 +- .../repository/analysis/AiChargeJpaRepository.java | 14 ++++++++++---- .../analysis/AiChargeRepositoryImpl.java | 7 +++++-- .../service/pranalysis/PrAnalysisService.java | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) 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 index 1e3b9de..83d6c61 100644 --- 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 @@ -6,5 +6,5 @@ public interface AiChargeRepository { AiCharge getByMonth(int month); - void addCharge(long id, double charge); + void addCharge(int month, double charge); } 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 index b0cb642..7e89814 100644 --- 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 @@ -14,9 +14,15 @@ public interface AiChargeJpaRepository extends JpaRepository= 0 + 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("id") long id, @Param("charge") double charge); + 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 index eb510c5..ed63771 100644 --- 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 @@ -28,7 +28,10 @@ public AiCharge getByMonth(int month) { } @Override - public void addCharge(long id, double charge) { - chargeJpaRepository.updateChargeById(id, charge); + public void addCharge(int month, double charge) { + ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); + LocalDateTime now = LocalDateTime.now(seoulZoneId); + int todayYear = now.getYear(); + chargeJpaRepository.updateChargeById(todayYear, month, charge); } } 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 d66ae34..667905b 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 @@ -18,7 +18,7 @@ public class PrAnalysisService { private final GithubAdaptor githubAdaptor; private final PrAnalysisAdapter prAnalysisAdapter; - private AiChargeRepository chargeRepository; + private final AiChargeRepository chargeRepository; public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) { String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); From 21129568dbf0dfd69ffb48dd44a221d6aff62ab6 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 02:38:30 +0900 Subject: [PATCH 20/33] =?UTF-8?q?test:=20=EB=AA=A8=EB=8D=B8=20selection=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/analysis/OpenAiModelTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 gss-domain/src/test/java/com/devoops/domain/entity/analysis/OpenAiModelTest.java 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 0000000..283fd80 --- /dev/null +++ b/gss-domain/src/test/java/com/devoops/domain/entity/analysis/OpenAiModelTest.java @@ -0,0 +1,47 @@ +package com.devoops.domain.entity.analysis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class OpenAiModelTest { + + @Nested + class ModelSelection { + + @Test + void GPT5_범위에서_선택된다() { + int lowerBound = OpenAiModel.GPT_5.getMoneyUnderCriteria(); + int upperBound = 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_범위에서_선택된다() { + int lowerBound = OpenAiModel.GPT_5_MINI.getMoneyUnderCriteria(); + int upperBound = 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_범위에서_선택된다() { + int lowerBound = OpenAiModel.GPT_5_NANO.getMoneyUnderCriteria(); + int upperBound = OpenAiModel.GPT_5_NANO.getMoneyUpperCriteria(); + + assertAll( + () -> assertThat(OpenAiModel.getModelByUsage(lowerBound)).isEqualTo(OpenAiModel.GPT_5_NANO), + () -> assertThat(OpenAiModel.getModelByUsage(upperBound)).isEqualTo(OpenAiModel.GPT_5_NANO) + ); + } + } +} From 61bd043ef5c0a0cce9ff02d5daafc27d85fca1a8 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 02:40:10 +0900 Subject: [PATCH 21/33] =?UTF-8?q?refactor:=20zoneId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devoops/service/pranalysis/PrAnalysisService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 667905b..98cd7a2 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 @@ -9,6 +9,7 @@ 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; @@ -22,7 +23,8 @@ public class PrAnalysisService { public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) { String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); - int month = LocalDate.now().getMonthValue(); + ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); + int month = LocalDate.now(seoulZoneId).getMonthValue(); AiCharge aiCharge = chargeRepository.getByMonth(month); OpenAiModel aiModel = OpenAiModel.getModelByUsage(aiCharge.getCharge()); @@ -32,6 +34,7 @@ public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest reques diff, aiModel.getName() ); + double consumedCharge = aiModel.getCharge(result.promptTokens(), result.completionTokens()); chargeRepository.addCharge(month, consumedCharge); return result; From 702594eb10af4d221884a715acb37bfd06e994f6 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 21 Aug 2025 02:41:50 +0900 Subject: [PATCH 22/33] =?UTF-8?q?refactor:=20unique=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devoops/jpa/entity/github/repo/GithubRepositoryEntity.java | 1 - 1 file changed, 1 deletion(-) 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 cb19269..685c582 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) { From 934adb8157c314281fb9fae7947af8e56bf028f0 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 07:01:50 +0900 Subject: [PATCH 23/33] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=AC=B8=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../facade/RepositoryFacadeService.java | 17 +++++++++ .../facade/RepositoryFacadeServiceTest.java | 18 +++++++++ .../repository/RepositoryServiceTest.java | 37 ++++++++++++------- .../entity/github/repo/GithubRepository.java | 4 ++ .../repo/GithubRepoDomainRepository.java | 5 ++- .../jpa/entity/analysis/AiChargeEntity.java | 3 ++ .../repo/GithubRepoDomainRepositoryImpl.java | 13 +++---- .../service/repository/RepositoryService.java | 18 ++++++--- .../generator/GithubRepoGenerator.java | 8 +++- .../service/webhook/WebhookFacadeService.java | 6 +-- 10 files changed, 94 insertions(+), 35 deletions(-) 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 fa2f138..c208826 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,15 @@ 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); + } + GithubRepository reTrackingRepo = repositoryService.reTracking(user, registeredRepo.getExternalId()); + webHookService.registerWebhook(user, reTrackingRepo.getId()); + return reTrackingRepo; + } + 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/service/facade/RepositoryFacadeServiceTest.java b/gss-api-app/src/test/java/com/devoops/service/facade/RepositoryFacadeServiceTest.java index 6917528..f7bd692 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 @@ -10,6 +10,7 @@ import com.devoops.BaseServiceTest; import com.devoops.client.GitHubClient; +import com.devoops.command.request.RepositoryCreateCommand; import com.devoops.domain.entity.github.repo.GithubRepository; import com.devoops.domain.entity.user.User; import com.devoops.domain.repository.github.repo.GithubRepoDomainRepository; @@ -81,6 +82,23 @@ 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() + ); + + 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 2d25b07..02c756e 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-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 68ff9ae..ba23e9a 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/github/repo/GithubRepoDomainRepository.java b/gss-domain/src/main/java/com/devoops/domain/repository/github/repo/GithubRepoDomainRepository.java index 25a60ea..f198df6 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 index 73434e1..1da7d2c 100644 --- 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 @@ -3,6 +3,7 @@ import com.devoops.domain.entity.analysis.AiCharge; import com.devoops.domain.entity.github.answer.Answer; import com.devoops.jpa.entity.github.answer.AnswerEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -27,8 +28,10 @@ public class AiChargeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "charge_year") private int year; + @Column(name = "charge_month") private int month; private double 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 9933957..22ea104 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 4755b2a..d10bfe4 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 org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,12 +22,19 @@ public class RepositoryService { private final PullRequestDomainRepository pullRequestRepository; public GithubRepository save(RepositoryCreateCommand 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/testFixtures/java/com/devoops/generator/GithubRepoGenerator.java b/gss-domain/src/testFixtures/java/com/devoops/generator/GithubRepoGenerator.java index eb2fcc0..5b9f7d3 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/service/webhook/WebhookFacadeService.java b/gss-mcp-app/src/main/java/com/devoops/service/webhook/WebhookFacadeService.java index fe1dbe5..5cdcef0 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); } From b7265e0fde281df05d9523db89bf952627c8badd Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 07:02:05 +0900 Subject: [PATCH 24/33] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-mcp-client.yml | 3 ++- gss-mcp-app/src/test/resources/application.yml | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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 3e740d2..ca3018c 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 @@ -9,8 +9,9 @@ dev-oops: - "category"는 기술적인 관점에서 PR 코드 변경 내용을 반영하여 선택해 (예: 성능, 보안, 확장성, 유지보수성, 테스트 등) - "question"은 각 category에 대해 기술 면접에서 사용할 수 있는 질문이어야 해. - 각 질문들은 반드시 PR 코드 변경 내용("diff")을 인용해서 생성해. - - "diff"를 굉장히 자세하게 분석하고 코드 일부를 반영해서 질문을 만들어줘 + - "diff"를 굉장히 자세하게 분석하고 몇몇 질문에는 코드를 반영해서 만들어줘 - 질문 수는 카테고리마다 3개 이상 만들어. + - 질문은 꼭 존댓말로 "?"로 끝나는 의문문이어야 해. prompt: | 당신은 숙련된 코드 리뷰어입니다. diff --git a/gss-mcp-app/src/test/resources/application.yml b/gss-mcp-app/src/test/resources/application.yml index f4a96b8..1b1a652 100644 --- a/gss-mcp-app/src/test/resources/application.yml +++ b/gss-mcp-app/src/test/resources/application.yml @@ -30,7 +30,6 @@ spring: ai: openai: api-key: test - chat.options.model: testModel dev-oops: mcp: @@ -45,8 +44,9 @@ dev-oops: - "category"는 기술적인 관점에서 PR 코드 변경 내용을 반영하여 선택해 (예: 성능, 보안, 확장성, 유지보수성, 테스트 등) - "question"은 각 category에 대해 기술 면접에서 사용할 수 있는 질문이어야 해. - 각 질문들은 반드시 PR 코드 변경 내용("diff")을 인용해서 생성해. - - "diff"를 굉장히 자세하게 분석하고 코드 일부를 반영해서 질문을 만들어줘 + - "diff"를 굉장히 자세하게 분석하고 몇몇 질문에는 코드를 반영해서 만들어줘 - 질문 수는 카테고리마다 3개 이상 만들어. + - 질문은 꼭 존댓말로 "?"로 끝나는 의문문이어야 해. prompt: | 당신은 숙련된 코드 리뷰어입니다. From 82415d09c03273810a49c9dc317d3e11d312649b Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 07:48:14 +0900 Subject: [PATCH 25/33] =?UTF-8?q?test:=20ai=EB=B9=84=EC=9A=A9=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/devoops/BaseRepositoryTest.java | 25 ++++++++ .../java/com/devoops/BaseServiceTest.java | 4 ++ .../analysis/AiChargeRepositoryTest.java | 57 +++++++++++++++++++ .../jpa/entity/analysis/AiChargeEntity.java | 5 -- .../analysis/AiChargeJpaRepository.java | 1 - .../analysis/AiChargeRepositoryImpl.java | 2 + .../java/com/devoops/BaseRepositoryTest.java | 13 ----- .../devoops/generator/AiChargeGenerator.java | 20 +++++++ 8 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 gss-api-app/src/test/java/com/devoops/BaseRepositoryTest.java create mode 100644 gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java delete mode 100644 gss-domain/src/testFixtures/java/com/devoops/BaseRepositoryTest.java create mode 100644 gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java 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 0000000..b6539cd --- /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 e7236cb..2e1df07 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 0000000..9864121 --- /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.getByMonth(localDate.getMonthValue()); + + assertThat(actual.getCharge()).isEqualTo(charge); + } + + @Test + void 가져올_요금이_없다면_초기화한다() { + int month = LocalDate.now().getMonthValue(); + + AiCharge actual = chargeRepository.getByMonth(month); + + assertThat(actual.getCharge()).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.getMonthValue(), charge); + + AiCharge updatedcharge = chargeRepository.getByMonth(localDate.getMonthValue()); + assertThat(updatedcharge.getCharge()).isEqualTo(charge * 2); + } + } +} 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 index 1da7d2c..fb121ed 100644 --- 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 @@ -1,17 +1,12 @@ package com.devoops.jpa.entity.analysis; import com.devoops.domain.entity.analysis.AiCharge; -import com.devoops.domain.entity.github.answer.Answer; -import com.devoops.jpa.entity.github.answer.AnswerEntity; 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 java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; 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 index 7e89814..4473943 100644 --- 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 @@ -9,7 +9,6 @@ public interface AiChargeJpaRepository extends JpaRepository { - Optional findByYearAndMonth(int todayYear, int todayMonth); @Modifying(clearAutomatically = true, flushAutomatically = true) 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 index ed63771..daea88c 100644 --- 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 @@ -7,6 +7,7 @@ import java.time.ZoneId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository @RequiredArgsConstructor @@ -28,6 +29,7 @@ public AiCharge getByMonth(int month) { } @Override + @Transactional public void addCharge(int month, double charge) { ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); LocalDateTime now = LocalDateTime.now(seoulZoneId); 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 8dfa388..0000000 --- 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 0000000..7dd5a0a --- /dev/null +++ b/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java @@ -0,0 +1,20 @@ +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 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, charge); + return repository.save(AiChargeEntity.from(aiCharge)) + .toDomainEntity(); + } +} From cc9c21ffc0e66e2b324ae3b9b5f86f67a4fb32eb Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 07:54:43 +0900 Subject: [PATCH 26/33] =?UTF-8?q?test:=20ai=ED=98=B8=EC=B6=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devoops/PrAnalysisClientImplTest.java | 929 +----------------- 1 file changed, 17 insertions(+), 912 deletions(-) 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 b3cfd9c..bffd715 100644 --- a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java +++ b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java @@ -3,12 +3,13 @@ import com.devoops.client.PrAnalysisClientImpl; 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 +@Disabled class PrAnalysisClientImplTest { @Autowired @@ -17,27 +18,22 @@ class PrAnalysisClientImplTest { @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); -// + } -// """; - - String title = "feat: MSW 세팅 및 일부 mock api 제작"; - String desc = """ - "\\r\\n\\r\\n## What?\\r\\n\\r\\nclose #94 \\r\\n\\r\\n- MSW(Mock Service Worker)에 대한 세팅을 진행하였습니다.\\r\\n- 모든 api에 대하여 작업을 해두기가 어려워, 테스트를 위해 홈에 들어가는 api를 우선적으로 작업해 두었습니다. \\r\\n(아래는 작업된 mock api 리스트 입니다.)\\r\\n\\r\\n|repositoriesHandler|userHandler|\\r\\n|:---:|:---:|\\r\\n|[/api/repositories/{repositoryId}/pull-requests](https://api.dev-oops.kr/swagger-ui/index.html#/Repository%20API/getRepositoryPullRequests)
[/api/repositories/me](https://api.dev-oops.kr/swagger-ui/index.html#/Repository%20API/getMyRepositories)
[/api/repositories/pull-requests](https://api.dev-oops.kr/swagger-ui/index.html#/Repository%20API/getRepositoryEntirePullRequests)
[/api/repositories/pull-requests/{pullRequestId}](https://api.dev-oops.kr/swagger-ui/index.html#/Pull%20Request%20API/getPullRequest)|[/api/users/me](https://api.dev-oops.kr/swagger-ui/index.html#/User%20API/getMyInfo)|\\r\\n\\r\\n- `.env.local`에 대한 변화가 있어, 노션에 추가해 두었습니다. ([링크](https://www.notion.so/Env-21bbee7f599680ca9d45fc980cf08baa?source=copy_link))\\r\\n\\r\\n## Why?\\r\\n\\r\\n- 백엔드가 먹통이 되니 프론트엔드 개발이 지연되는 문제를 방지하고 싶었습니다.\\r\\n- 또한 차후 백엔드 작업으로 부터 의존성을 제거할 수 있다고 생각해 진행하게 되었습니다.\\r\\n\\r\\n## How?\\r\\n\\r\\n

아.. 이번 일을 왜 시작했을까 싶을정도로 하면서 후회를...

\\r\\n\\r\\n서버가 멀쩡하게 돌아가는게 얼마나 감사한지 깨달았습니다.\\r\\n(MSW를 tanstack, Next app router과 함께 쓰려니 설정하는 부분에 있어서 수많은 문제들이 있었습니다..)\\r\\n\\r\\n### 📌 MSW에 대한 간단한 설명\\r\\n\\r\\nMSW는 크게 다음과 같은 역할을 한다고 생각하시면 됩니다.\\r\\n- 브라우저가 실제 서버로 보내려는 네트워크 요청을 가로챈다.\\r\\n- 개발자가 정의한 핸들러(handler)를 통해, 미리 설정해둔 값을 반환한다.\\r\\n\\r\\n### 📌 mocks 폴더 구조에 대한 설명\\r\\n\\r\\nmocks 폴더는 거의 MSW를 위해 만들어 졌다고 보시면 됩니다. \\r\\n(공식문서에서도 이렇게 작업하라고 되어 있어용.)\\r\\n\\r\\n```\\r\\nmocks/\\r\\n├── handlers/ <- API 요청(내 정보 조회 등)을 어떻게 처리할지 정의하는 폴더\\r\\n│ ├── repositoriesHandler.ts\\r\\n│ └── userHandler.ts\\r\\n├── responses/ <- mock response를 정의 하는 파일 (하위 폴더는 apis 처럼 endpoint를 기준으로 나눔)\\r\\n│ ├── repositories/\\r\\n│ │ ├── getEntirePullRequests.json\\r\\n│ │ ├── getPullRequest.json\\r\\n│ │ ├── getRepositoriesMe.json\\r\\n│ │ └── getRepositoryPullRequests.json\\r\\n│ ├── user/ \\r\\n│ └── getMyInfo.json\\r\\n├── browser.ts <- 브라우저에서 실행하기 위한 파일\\r\\n├── handlers.ts <- 위의 /handlers 폴더에서 정의한 파일을 다 묶은 파일\\r\\n├── index.ts <- 실행에 대한 로직이 들어있는 파일\\r\\n└── server.ts <- 서버에서 돌하가게 하기 위한 파일\\r\\n```\\r\\n\\r\\n### 📌 실행 위해 알아두어야 할 것\\r\\n\\r\\nMSW는 개발을 위한 것이므로 production 환경에서는 돌아갈 필요가 없습니다.\\r\\n그래서 `process.env.NODE_ENV !== \\"production\\"`으로 처리할까 하다가, 사용자가 원할때 MSW를 껏다 켰다 쉽게 하면 좋을 것 같아서 `.env.local`에 `NEXT_PUBLIC_MOCK_API` 값을 통해서 제어하는 방식으로 해 두었습니다. \\r\\n\\r\\n```\\r\\nNEXT_PUBLIC_MOCK_API=\\"enabled\\" // 실행 o\\r\\nNEXT_PUBLIC_MOCK_API=\\"disabled\\" // 실행 x\\r\\n```\\r\\n|\\"스크린샷|\\r\\n|:---:|\\r\\n|정상적으로 실행되면, 다음과 같이 빨간색으로 실행되었다는 문구가 뜹니다.|\\r\\n\\r\\n- 추가1) msw 뜰때는 token과 상관없이 개발이 되어야 한다 생각해서 홈으로 접근해도 landing으로 redirect 되지 않도록 처리 했습니다\\r\\n- 추가2) `/api/repositories/${repositoryId}/pull-requests`과 같이 인자에 따라서 결과가 달라지는 api의 경우 인자의 값이 달라도 동일한 값이 전달되도록 해 두었습니다.\\r\\n\\r\\n### 📌 실행 화면\\r\\n\\r\\n|\\"스크린샷|\\"스크린샷|\\r\\n|:---:|:---:|\\r\\n|처음 실행하면 다음과 같이 뜰수도 있는데 새로고침 하시면 됩니다!|전체와 첫번째 레포의 경우 문제없이 동작|\\r\\n\\r\\n|\\"스크린샷|\\r\\n|:---:|\\r\\n|두번째 레포의 경우 handler에서 404 에러가 발생하도록 해둠|\\r\\n\\r\\n## Check List\\r\\n\\r\\n- [x] Merge 할 브랜치가 올바른가?\\r\\n\\r\\n\\r\\n\\n## Summary by CodeRabbit\\n\\n* **New Features**\\n * Mock API 모드 도입(NEXT_PUBLIC_MOCK_API='enabled'): MSW 기반 클라이언트/서버 초기화와 자동 활성화로 사용자·레포/PR 응답 시뮬레이션 제공.\\n * 여러 모의 핸들러와 JSON 응답 추가로 목록·상세·사용자 정보 뷰 테스트 가능.\\n\\n* **Chores**\\n * msw 개발 의존성 및 루트 설정 추가, 서비스워커 스크립트 타입검사 제외.\\n * 라우트 상수(ROUTES.API) 대거 추가·정리 및 RETROSPECTIVE 파라미터 필수화.\\n\\n* **Style**\\n * ESLint 규칙 추가/경고화로 코드 스타일 강화.\\n\\n* **Behavior**\\n * 미들웨어: Mock 모드일 때 보호 경로 토큰 검사 건너뜀.\\n\\n* **Other**\\n * 애플리케이션 상수(페이지당 항목 수 등) 추가.\\n" + 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); + + } """; - String diff = example(); + long startTime = System.currentTimeMillis(); AnalyzePrRequest request = new AnalyzePrRequest(title, desc, diff, "gpt-5-nano"); PrAnalysis result = prAnalysisClient.analyze(request).prAnalysis(); @@ -49,895 +45,4 @@ void analyzePr_shouldReturnSummaryAndQuestions() { result.summaryDetails().forEach(q -> System.out.println("- " + q)); result.questions().forEach(q -> System.out.println("- " + q)); } - - private String example() { - return """ - diff --git a/.eslintrc.json b/.eslintrc.json - index e8e37be..bfb69c7 100644 - --- a/.eslintrc.json - +++ b/.eslintrc.json - @@ -42,6 +42,14 @@ - "unnamedComponents": "arrow-function" - } - ], - + "import/no-extraneous-dependencies": [ - + "error", - + { - + "devDependencies": [ - + "**/mocks/**" - + ] - + } - + ], - "react/jsx-curly-brace-presence": ["warn", { "props": "always", "children": "always" }], - "react/jsx-props-no-spreading": "off", - "arrow-body-style": "off", - @@ -62,7 +70,9 @@ - "better-tailwindcss/no-unregistered-classes": "off", - "better-tailwindcss/enforce-consistent-line-wrapping": "off", - "react/button-has-type": "off", - - "@typescript-eslint/no-unused-vars": "error" - + "@typescript-eslint/no-unused-vars": "error", - + "class-methods-use-this": "warn", - + "react/jsx-no-useless-fragment": "warn" - }, - "ignorePatterns": ["node_modules/"] - } - diff --git a/package.json b/package.json - index 1c8aebc..0824817 100644 - --- a/package.json - +++ b/package.json - @@ -55,6 +55,7 @@ - "eslint-plugin-react-hooks": "^5.2.0", - "husky": "^9.1.7", - "lint-staged": "^15.5.2", - + "msw": "^2.10.5", - "prettier": "^3.5.3", - "tailwindcss": "^4", - "tw-animate-css": "^1.3.5", - @@ -74,5 +75,10 @@ - "npm": "please-use-yarn", - "pnpm": "please-use-yarn", - "yarn": "^1.22.x" - + }, - + "msw": { - + "workerDirectory": [ - + "public" - + ] - } - -} - +} - \\ No newline at end of file - diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js - new file mode 100644 - index 0000000..723b071 - --- /dev/null - +++ b/public/mockServiceWorker.js - @@ -0,0 +1,344 @@ - +/* eslint-disable */ - +/* tslint:disable */ - + - +/** - + * Mock Service Worker. - + * @see https://github.com/mswjs/msw - + * - Please do NOT modify this file. - + */ - + - +const PACKAGE_VERSION = '2.10.5' - +const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' - +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') - +const activeClientIds = new Set() - + - +addEventListener('install', function () { - + self.skipWaiting() - +}) - + - +addEventListener('activate', function (event) { - + event.waitUntil(self.clients.claim()) - +}) - + - +addEventListener('message', async function (event) { - + const clientId = Reflect.get(event.source || {}, 'id') - + - + if (!clientId || !self.clients) { - + return - + } - + - + const client = await self.clients.get(clientId) - + - + if (!client) { - + return - + } - + - + const allClients = await self.clients.matchAll({ - + type: 'window', - + }) - + - + switch (event.data) { - + case 'KEEPALIVE_REQUEST': { - + sendToClient(client, { - + type: 'KEEPALIVE_RESPONSE', - + }) - + break - + } - + - + case 'INTEGRITY_CHECK_REQUEST': { - + sendToClient(client, { - + type: 'INTEGRITY_CHECK_RESPONSE', - + payload: { - + packageVersion: PACKAGE_VERSION, - + checksum: INTEGRITY_CHECKSUM, - + }, - + }) - + break - + } - + - + case 'MOCK_ACTIVATE': { - + activeClientIds.add(clientId) - + - + sendToClient(client, { - + type: 'MOCKING_ENABLED', - + payload: { - + client: { - + id: client.id, - + frameType: client.frameType, - + }, - + }, - + }) - + break - + } - + - + case 'MOCK_DEACTIVATE': { - + activeClientIds.delete(clientId) - + break - + } - + - + case 'CLIENT_CLOSED': { - + activeClientIds.delete(clientId) - + - + const remainingClients = allClients.filter((client) => { - + return client.id !== clientId - + }) - + - + // Unregister itself when there are no more clients - + if (remainingClients.length === 0) { - + self.registration.unregister() - + } - + - + break - + } - + } - +}) - + - +addEventListener('fetch', function (event) { - + // Bypass navigation requests. - + if (event.request.mode === 'navigate') { - + return - + } - + - + // Opening the DevTools triggers the "only-if-cached" request - + // that cannot be handled by the worker. Bypass such requests. - + if ( - + event.request.cache === 'only-if-cached' && - + event.request.mode !== 'same-origin' - + ) { - + return - + } - + - + // Bypass all requests when there are no active clients. - + // Prevents the self-unregistered worked from handling requests - + // after it's been deleted (still remains active until the next reload). - + if (activeClientIds.size === 0) { - + return - + } - + - + const requestId = crypto.randomUUID() - + event.respondWith(handleRequest(event, requestId)) - +}) - + - +/** - + * @param {FetchEvent} event - + * @param {string} requestId - + */ - +async function handleRequest(event, requestId) { - + const client = await resolveMainClient(event) - + const requestCloneForEvents = event.request.clone() - + const response = await getResponse(event, client, requestId) - + - + // Send back the response clone for the "response:*" life-cycle events. - + // Ensure MSW is active and ready to handle the message, otherwise - + // this message will pend indefinitely. - + if (client && activeClientIds.has(client.id)) { - + const serializedRequest = await serializeRequest(requestCloneForEvents) - + - + // Clone the response so both the client and the library could consume it. - + const responseClone = response.clone() - + - + sendToClient( - + client, - + { - + type: 'RESPONSE', - + payload: { - + isMockedResponse: IS_MOCKED_RESPONSE in response, - + request: { - + id: requestId, - + ...serializedRequest, - + }, - + response: { - + type: responseClone.type, - + status: responseClone.status, - + statusText: responseClone.statusText, - + headers: Object.fromEntries(responseClone.headers.entries()), - + body: responseClone.body, - + }, - + }, - + }, - + responseClone.body ? [serializedRequest.body, responseClone.body] : [], - + ) - + } - + - + return response - +} - + - +/** - + * Resolve the main client for the given event. - + * Client that issues a request doesn't necessarily equal the client - + * that registered the worker. It's with the latter the worker should - + * communicate with during the response resolving phase. - + * @param {FetchEvent} event - + * @returns {Promise} - + */ - +async function resolveMainClient(event) { - + const client = await self.clients.get(event.clientId) - + - + if (activeClientIds.has(event.clientId)) { - + return client - + } - + - + if (client?.frameType === 'top-level') { - + return client - + } - + - + const allClients = await self.clients.matchAll({ - + type: 'window', - + }) - + - + return allClients - + .filter((client) => { - + // Get only those clients that are currently visible. - + return client.visibilityState === 'visible' - + }) - + .find((client) => { - + // Find the client ID that's recorded in the - + // set of clients that have registered the worker. - + return activeClientIds.has(client.id) - + }) - +} - + - +/** - + * @param {FetchEvent} event - + * @param {Client | undefined} client - + * @param {string} requestId - + * @returns {Promise} - + */ - +async function getResponse(event, client, requestId) { - + // Clone the request because it might've been already used - + // (i.e. its body has been read and sent to the client). - + const requestClone = event.request.clone() - + - + function passthrough() { - + // Cast the request headers to a new Headers instance - + // so the headers can be manipulated with. - + const headers = new Headers(requestClone.headers) - + - + // Remove the "accept" header value that marked this request as passthrough. - + // This prevents request alteration and also keeps it compliant with the - + // user-defined CORS policies. - + const acceptHeader = headers.get('accept') - + if (acceptHeader) { - + const values = acceptHeader.split(',').map((value) => value.trim()) - + const filteredValues = values.filter( - + (value) => value !== 'msw/passthrough', - + ) - + - + if (filteredValues.length > 0) { - + headers.set('accept', filteredValues.join(', ')) - + } else { - + headers.delete('accept') - + } - + } - + - + return fetch(requestClone, { headers }) - + } - + - + // Bypass mocking when the client is not active. - + if (!client) { - + return passthrough() - + } - + - + // Bypass initial page load requests (i.e. static assets). - + // The absence of the immediate/parent client in the map of the active clients - + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - + // and is not ready to handle requests. - + if (!activeClientIds.has(client.id)) { - + return passthrough() - + } - + - + // Notify the client that a request has been intercepted. - + const serializedRequest = await serializeRequest(event.request) - + const clientMessage = await sendToClient( - + client, - + { - + type: 'REQUEST', - + payload: { - + id: requestId, - + ...serializedRequest, - + }, - + }, - + [serializedRequest.body], - + ) - + - + switch (clientMessage.type) { - + case 'MOCK_RESPONSE': { - + return respondWithMock(clientMessage.data) - + } - + - + case 'PASSTHROUGH': { - + return passthrough() - + } - + } - + - + return passthrough() - +} - + - +/** - + * @param {Client} client - + * @param {any} message - + * @param {Array} transferrables - + * @returns {Promise} - + */ - +function sendToClient(client, message, transferrables = []) { - + return new Promise((resolve, reject) => { - + const channel = new MessageChannel() - + - + channel.port1.onmessage = (event) => { - + if (event.data && event.data.error) { - + return reject(event.data.error) - + } - + - + resolve(event.data) - + } - + - + client.postMessage(message, [ - + channel.port2, - + ...transferrables.filter(Boolean), - + ]) - + }) - +} - + - +/** - + * @param {Response} response - + * @returns {Response} - + */ - +function respondWithMock(response) { - + // Setting response status code to 0 is a no-op. - + // However, when responding with a "Response.error()", the produced Response - + // instance will have status code set to 0. Since it's not possible to create - + // a Response instance with status code 0, handle that use-case separately. - + if (response.status === 0) { - + return Response.error() - + } - + - + const mockedResponse = new Response(response.body, response) - + - + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - + value: true, - + enumerable: true, - + }) - + - + return mockedResponse - +} - + - +/** - + * @param {Request} request - + */ - +async function serializeRequest(request) { - + return { - + url: request.url, - + mode: request.mode, - + method: request.method, - + headers: Object.fromEntries(request.headers.entries()), - + cache: request.cache, - + credentials: request.credentials, - + destination: request.destination, - + integrity: request.integrity, - + redirect: request.redirect, - + referrer: request.referrer, - + referrerPolicy: request.referrerPolicy, - + body: await request.arrayBuffer(), - + keepalive: request.keepalive, - + } - +} - diff --git a/src/apis/github/github.api.ts b/src/apis/github/github.api.ts - index 5e4d072..81c8cba 100644 - --- a/src/apis/github/github.api.ts - +++ b/src/apis/github/github.api.ts - @@ -1,5 +1,3 @@ - -/* eslint-disable class-methods-use-this */ - - - export const GITHUB_API_URL = 'https://github.com'; - \s - class GithubApi { - diff --git a/src/app/layout.tsx b/src/app/layout.tsx - index 6190826..b92d475 100644 - --- a/src/app/layout.tsx - +++ b/src/app/layout.tsx - @@ -2,7 +2,9 @@ import type { Metadata } from 'next'; - import { ReactNode } from 'react'; - \s - import GASuspense from '@/components/common/GA/GASuspense'; - +import { initMSW } from '@/mocks'; - import ModalProvider from '@/providers/ModalContext'; - +import MSWClientProvider from '@/providers/MSWClientProvider'; - import QueryProvider from '@/providers/QueryProvider'; - \s - import './globals.css'; - @@ -26,10 +28,15 @@ export default function RootLayout({ - }: Readonly<{ - children: ReactNode; - }>) { - + if (process.env.NEXT_PUBLIC_MOCK_API === 'enabled') { - + initMSW(); - + } - + - return ( - - - - + {process.env.NEXT_PUBLIC_MOCK_API === 'enabled' ? : null} - {children} -
- - diff --git a/src/constants/domain.ts b/src/constants/domain.ts - new file mode 100644 - index 0000000..74f1236 - --- /dev/null - +++ b/src/constants/domain.ts - @@ -0,0 +1,2 @@ - +export const ITEMS_PER_PAGE = 5; - +export const NO_PR_IN_REPOSITORY_ID = 20010903; - diff --git a/src/constants/routes.ts b/src/constants/routes.ts - index aff85dc..249683f 100644 - --- a/src/constants/routes.ts - +++ b/src/constants/routes.ts - @@ -4,6 +4,42 @@ export const ROUTES = { - LANDING: '/landing', - AUTH_GITHUB: '/auth/github', - REPOLINK: '/repolink', - - RETROSPECTIVE: (prId: number | undefined) => `/retrospective/${prId}`, - + RETROSPECTIVE: (pullRequestId: number) => `/retrospective/${pullRequestId}`, - + }, - + API: { - + // Auth API - + LOGOUT_V1: '/api/v1/auth/logout', - + REISSUE_TOKEN_V1: '/api/v1/auth/github/refresh', - + LOGOUT: '/api/auth/logout', - + ISSUE_TOKEN: '/api/auth/github', - + REISSUE_TOKEN: '/api/auth/github/refresh', - + - + // Question API - + UPDATE_ALL_ANSWERS: '/api/questions/answer', - + CREATE_ANSWER: (questionId: number | string) => `/api/questions/${questionId}/answer`, - + DELETE_ANSWER: (answerId: number | string) => `/api/questions/answer/${answerId}`, - + UPDATE_ANSWER: (answerId: number | string) => `/api/questions/answer/${answerId}`, - + - + // GitHub API - + REGISTER_WEBHOOK: (repositoryId: number | string) => `/api/v1/github/repositories/${repositoryId}/webhooks`, - + - + // Repository API - + SAVE_REPOSITORY: '/api/repositories', - + REPOSITORY_PULL_REQUESTS: (repositoryId: number | string) => `/api/repositories/${repositoryId}/pull-requests`, - + ENTIRE_PULL_REQUESTS: '/api/repositories/pull-requests', - + MY_REPOSITORIES: '/api/repositories/me', - + DELETE_REPOSITORY: (repositoryId: number | string) => `/api/repositories/${repositoryId}`, - + - + // Pull Request API - + UPDATE_PULL_REQUEST_TO_DONE: (pullRequestId: number | string) => `/api/pull-requests/${pullRequestId}/done`, - + GET_PULL_REQUEST: (pullRequestId: number | string) => `/api/repositories/pull-requests/${pullRequestId}`, - + GET_DETAIL_PULL_REQUEST: (pullRequestId: number | string) => `/api/pull-requests/${pullRequestId}`, - + GET_PULL_REQUEST_RANKING: '/api/pull-requests/ranking', - + - + // User API - + GET_MY_INFO: '/api/users/me', - + - + // Ping API - + PING: '/api/ping', - }, - } as const; - diff --git a/src/middleware.ts b/src/middleware.ts - index 64b5536..a793066 100644 - --- a/src/middleware.ts - +++ b/src/middleware.ts - @@ -19,7 +19,7 @@ export async function middleware(req: NextRequest) { - return pathname === pattern || pathname.startsWith(pattern); - }); - \s - - if (isProtected) { - + if (process.env.NEXT_PUBLIC_MOCK_API !== 'enabled' && isProtected) { - const token = req.cookies.get(TOKEN_KEY)?.value; - \s - if (!token) { - diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts - new file mode 100644 - index 0000000..c680017 - --- /dev/null - +++ b/src/mocks/browser.ts - @@ -0,0 +1,5 @@ - +import { setupWorker } from 'msw/browser'; - + - +import handlers from '@/mocks/handlers'; - + - +export const worker = setupWorker(...handlers); - diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts - new file mode 100644 - index 0000000..c76fa82 - --- /dev/null - +++ b/src/mocks/handlers.ts - @@ -0,0 +1,8 @@ - +import { type HttpHandler } from 'msw'; - + - +import repositoriesHandler from '@/mocks/handlers/repositoriesHandler'; - +import userHandler from '@/mocks/handlers/userHandler'; - + - +const handlers: HttpHandler[] = [...userHandler, ...repositoriesHandler]; - + - +export default handlers; - diff --git a/src/mocks/handlers/repositoriesHandler.ts b/src/mocks/handlers/repositoriesHandler.ts - new file mode 100644 - index 0000000..ce04b49 - --- /dev/null - +++ b/src/mocks/handlers/repositoriesHandler.ts - @@ -0,0 +1,34 @@ - +import { HttpResponse, http } from 'msw'; - + - +import { NO_PR_IN_REPOSITORY_ID } from '@/constants/domain'; - +import { ROUTES } from '@/constants/routes'; - +import getEntirePullRequests from '@/mocks/responses/repositories/getEntirePullRequests.json'; - +import getPullRequest from '@/mocks/responses/repositories/getPullRequest.json'; - +import getRepositoriesMe from '@/mocks/responses/repositories/getRepositoriesMe.json'; - +import getRepositoryPullRequests from '@/mocks/responses/repositories/getRepositoryPullRequests.json'; - + - +const repositoriesHandler = [ - + http.get(`*${ROUTES.API.MY_REPOSITORIES}`, () => { - + return HttpResponse.json(getRepositoriesMe, { status: 200 }); - + }), - + - + http.get(`*${ROUTES.API.REPOSITORY_PULL_REQUESTS(':repositoryId')}`, ({ params }) => { - + const { repositoryId } = params; - + - + if (Number(repositoryId) === NO_PR_IN_REPOSITORY_ID) { - + return new HttpResponse(null, { status: 404 }); - + } - + - + return HttpResponse.json(getRepositoryPullRequests, { status: 200 }); - + }), - + - + http.get(`*${ROUTES.API.ENTIRE_PULL_REQUESTS}`, () => { - + return HttpResponse.json(getEntirePullRequests, { status: 200 }); - + }), - + - + http.get(`*${ROUTES.API.GET_PULL_REQUEST(':pullRequestId')}`, () => { - + return HttpResponse.json(getPullRequest, { status: 200 }); - + }), - +]; - + - +export default repositoriesHandler; - diff --git a/src/mocks/handlers/userHandler.ts b/src/mocks/handlers/userHandler.ts - new file mode 100644 - index 0000000..32ceb20 - --- /dev/null - +++ b/src/mocks/handlers/userHandler.ts - @@ -0,0 +1,12 @@ - +import { HttpResponse, http } from 'msw'; - + - +import { ROUTES } from '@/constants/routes'; - +import getMyInfo from '@/mocks/responses/user/getMyInfo.json'; - + - +const userHandler = [ - + http.get(`*${ROUTES.API.GET_MY_INFO}`, () => { - + return HttpResponse.json(getMyInfo, { status: 200 }); - + }), - +]; - + - +export default userHandler; - diff --git a/src/mocks/index.ts b/src/mocks/index.ts - new file mode 100644 - index 0000000..aa24ca6 - --- /dev/null - +++ b/src/mocks/index.ts - @@ -0,0 +1,9 @@ - +export async function initMSW() { - + if (typeof window === 'undefined') { - + const { server } = await import('./server'); - + server.listen(); - + } else { - + const { worker } = await import('./browser'); - + worker.start(); - + } - +} - diff --git a/src/mocks/responses/repositories/getEntirePullRequests.json b/src/mocks/responses/repositories/getEntirePullRequests.json - new file mode 100644 - index 0000000..a4c3e75 - --- /dev/null - +++ b/src/mocks/responses/repositories/getEntirePullRequests.json - @@ -0,0 +1,44 @@ - +{ - + "pullRequests": [ - + { - + "id": 100, - + "title": "[FIX] 서버 장애 대응", - + "recordStatus": "PENDING", - + "mergedAt": "2025-05-01", - + "summary": "이번 장애에서의 문제 상황과 대응 과정을 정리하였습니다.", - + "tag": "feat" - + }, - + { - + "id": 101, - + "title": "[ADD] CI/CD 파이프라인 구축", - + "recordStatus": "PROGRESS", - + "mergedAt": "2025-05-02", - + "summary": "GitHub Actions를 활용한 CI/CD 파이프라인을 구축하여 자동 배포 시스템을 마련했습니다.", - + "tag": "chore" - + }, - + { - + "id": 102, - + "title": "[FEAT] 사용자 프로필 페이지 구현", - + "recordStatus": "PENDING", - + "mergedAt": "2025-05-03", - + "summary": "사용자가 자신의 정보를 확인하고 수정할 수 있는 프로필 페이지를 개발 중입니다.", - + "tag": "feat" - + }, - + { - + "id": 103, - + "title": "[REFACTOR] 인증 로직 개선", - + "recordStatus": "PROGRESS", - + "mergedAt": "2025-05-04", - + "summary": "기존 인증 로직의 가독성과 유지보수성을 향상시키기 위해 코드를 리팩토링했습니다.", - + "tag": "refactor" - + }, - + { - + "id": 104, - + "title": "[DOCS] API 문서 업데이트", - + "recordStatus": "DONE", - + "mergedAt": "2025-05-05", - + "summary": "최신 API 변경사항을 반영하여 개발자 문서를 업데이트했습니다.", - + "tag": "docs" - + } - + ] - +} - diff --git a/src/mocks/responses/repositories/getPullRequest.json b/src/mocks/responses/repositories/getPullRequest.json - new file mode 100644 - index 0000000..99c8496 - --- /dev/null - +++ b/src/mocks/responses/repositories/getPullRequest.json - @@ -0,0 +1,60 @@ - +{ - + "id": 100, - + "title": "[FIX] 서버 장애 대응", - + "recordStatus": "PENDING", - + "mergedAt": "2025-05-01", - + "summary": "이번 장애에서의 문제 상황과 대응 과정을 정리하였습니다.", - + "tag": "feat", - + "pullRequestUrl": "/", - + "categories": ["성능", "가독성", "테스트"], - + "questions": [ - + { - + "id": 200, - + "isSelected": true, - + "category": "성능", - + "content": "성능적으로 좋은 선택이라 생각하나요?", - + "createdAt": "2025-06-24T15:29:45Z", - + "updatedAt": "2025-06-24T15:29:45Z" - + }, - + { - + "id": 201, - + "isSelected": false, - + "category": "가독성", - + "content": "이 코드는 다른 사람이 쉽게 이해할 수 있나요?", - + "createdAt": "2025-06-25T10:00:00Z", - + "updatedAt": "2025-06-25T10:00:00Z" - + }, - + { - + "id": 202, - + "isSelected": false, - + "category": "테스트", - + "content": "작성한 테스트 코드가 충분한가요?", - + "createdAt": "2025-06-26T11:30:00Z", - + "updatedAt": "2025-06-26T11:30:00Z" - + }, - + { - + "id": 203, - + "isSelected": true, - + "category": "성능", - + "content": "성능적으로 좋은 선택이라 생각하나요? 22", - + "createdAt": "2025-06-24T15:29:45Z", - + "updatedAt": "2025-06-24T15:29:45Z" - + }, - + { - + "id": 204, - + "isSelected": false, - + "category": "가독성", - + "content": "이 코드는 다른 사람이 쉽게 이해할 수 있나요? 22", - + "createdAt": "2025-06-25T10:00:00Z", - + "updatedAt": "2025-06-25T10:00:00Z" - + }, - + { - + "id": 205, - + "isSelected": false, - + "category": "테스트", - + "content": "작성한 테스트 코드가 충분한가요? 22", - + "createdAt": "2025-06-26T11:30:00Z", - + "updatedAt": "2025-06-26T11:30:00Z" - + } - + ] - +} - diff --git a/src/mocks/responses/repositories/getRepositoriesMe.json b/src/mocks/responses/repositories/getRepositoriesMe.json - new file mode 100644 - index 0000000..03fd71c - --- /dev/null - +++ b/src/mocks/responses/repositories/getRepositoriesMe.json - @@ -0,0 +1,14 @@ - +{ - + "repositories": [ - + { - + "id": 1, - + "name": "첫번째 레포", - + "pullRequestCount": 5 - + }, - + { - + "id": 20010903, - + "name": "두번째 레포", - + "pullRequestCount": 3 - + } - + ] - +} - diff --git a/src/mocks/responses/repositories/getRepositoryPullRequests.json b/src/mocks/responses/repositories/getRepositoryPullRequests.json - new file mode 100644 - index 0000000..9da3e9e - --- /dev/null - +++ b/src/mocks/responses/repositories/getRepositoryPullRequests.json - @@ -0,0 +1,44 @@ - +{ - + "pullRequests": [ - + { - + "id": 301, - + "title": "[FEAT] 다크 모드 지원 추가", - + "recordStatus": "DONE", - + "mergedAt": "2025-06-10", - + "summary": "사용자 경험 향상을 위해 애플리케이션 전체에 다크 모드 테마를 적용했습니다.", - + "tag": "feat" - + }, - + { - + "id": 302, - + "title": "[FIX] 로그인 시 간헐적 500 에러 수정", - + "recordStatus": "PROGRESS", - + "mergedAt": "2025-06-11", - + "summary": "특정 조건에서 로그인 요청 시 발생하던 서버 내부 오류를 해결하고 있습니다.", - + "tag": "fix" - + }, - + { - + "id": 303, - + "title": "[CHORE] 의존성 라이브러리 버전 업데이트", - + "recordStatus": "DONE", - + "mergedAt": "2025-06-12", - + "summary": "보안 및 성능 개선을 위해 주요 npm 패키지들을 최신 버전으로 업데이트했습니다.", - + "tag": "chore" - + }, - + { - + "id": 304, - + "title": "[REFACTOR] 전역 상태 관리 로직 개선", - + "recordStatus": "PENDING", - + "mergedAt": "2025-06-13", - + "summary": "기존 Context API 기반 상태 관리를 Recoil로 마이그레이션하여 성능을 최적화합니다.", - + "tag": "refactor" - + }, - + { - + "id": 305, - + "title": "[TEST] E2E 테스트 케이스 추가", - + "recordStatus": "DONE", - + "mergedAt": "2025-06-14", - + "summary": "주요 사용자 플로우에 대한 Cypress E2E 테스트 코드를 추가하여 안정성을 높였습니다.", - + "tag": "test" - + } - + ] - +} - diff --git a/src/mocks/responses/user/getMyInfo.json b/src/mocks/responses/user/getMyInfo.json - new file mode 100644 - index 0000000..ec5535e - --- /dev/null - +++ b/src/mocks/responses/user/getMyInfo.json - @@ -0,0 +1,5 @@ - +{ - + "id": 1, - + "nickname": "김유저", - + "profileImageUrl": "https://avatars.githubusercontent.com/u/96560039?s=80&v=4" - +} - diff --git a/src/mocks/server.ts b/src/mocks/server.ts - new file mode 100644 - index 0000000..c4db1a8 - --- /dev/null - +++ b/src/mocks/server.ts - @@ -0,0 +1,5 @@ - +import { setupServer } from 'msw/node'; - + - +import handlers from '@/mocks/handlers'; - + - +export const server = setupServer(...handlers); - diff --git a/src/providers/MSWClientProvider.tsx b/src/providers/MSWClientProvider.tsx - new file mode 100644 - index 0000000..db3994f - --- /dev/null - +++ b/src/providers/MSWClientProvider.tsx - @@ -0,0 +1,13 @@ - +'use client'; - + - +import { useEffect } from 'react'; - + - +import { initMSW } from '@/mocks'; - + - +export default function MSWClientProvider() { - + useEffect(() => { - + initMSW(); - + }, []); - + - + return null; - +} - diff --git a/tsconfig.json b/tsconfig.json - index 77f7e72..a89ba4b 100644 - --- a/tsconfig.json - +++ b/tsconfig.json - @@ -23,5 +23,5 @@ - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "@types/*.d.ts"], - - "exclude": ["node_modules"] - + "exclude": ["node_modules", "public/mockServiceWorker.js"] - } - diff --git a/yarn.lock b/yarn.lock - index f8d7da3..b1226e5 100644 - --- a/yarn.lock - +++ b/yarn.lock - @@ -5411,6 +5411,30 @@ ms@^2.1.1, ms@^2.1.3: - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - \s - +msw@^2.10.5: - + version "2.10.5" - + resolved "https://registry.yarnpkg.com/msw/-/msw-2.10.5.tgz#3e43f12e97581c260bf38d8817732b9fec3bfdb0" - + integrity sha512-0EsQCrCI1HbhpBWd89DvmxY6plmvrM96b0sCIztnvcNHQbXn5vqwm1KlXslo6u4wN9LFGLC1WFjjgljcQhe40A== - + dependencies: - + "@bundled-es-modules/cookie" "^2.0.1" - + "@bundled-es-modules/statuses" "^1.0.1" - + "@bundled-es-modules/tough-cookie" "^0.1.6" - + "@inquirer/confirm" "^5.0.0" - + "@mswjs/interceptors" "^0.39.1" - + "@open-draft/deferred-promise" "^2.2.0" - + "@open-draft/until" "^2.1.0" - + "@types/cookie" "^0.6.0" - + "@types/statuses" "^2.0.4" - + graphql "^16.8.1" - + headers-polyfill "^4.0.2" - + is-node-process "^1.2.0" - + outvariant "^1.4.3" - + path-to-regexp "^6.3.0" - + picocolors "^1.1.1" - + strict-event-emitter "^0.5.1" - + type-fest "^4.26.1" - + yargs "^17.7.2" - + - msw@^2.7.1: - version "2.10.3" - resolved "https://registry.yarnpkg.com/msw/-/msw-2.10.3.tgz#accd0925d2852e9aaa2c86d4fdd724288fee5f35" - - """; - } } From fa7db45f469ca54ba891dde4578c8167324a1009 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 13:19:01 +0900 Subject: [PATCH 27/33] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=9B=B9?= =?UTF-8?q?=ED=9B=85=20=EB=93=B1=EB=A1=9D=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devoops/service/facade/RepositoryFacadeService.java | 4 +--- .../devoops/service/facade/RepositoryFacadeServiceTest.java | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 c208826..85d4608 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 @@ -69,9 +69,7 @@ private GithubRepository reTrackingOrThrowException(User user, GithubRepository if(registeredRepo.isTracking()) { throw new GssException(ErrorCode.ALREADY_SAVED_REPOSITORY); } - GithubRepository reTrackingRepo = repositoryService.reTracking(user, registeredRepo.getExternalId()); - webHookService.registerWebhook(user, reTrackingRepo.getId()); - return reTrackingRepo; + return repositoryService.reTracking(user, registeredRepo.getExternalId()); } public PullRequests findAllPullRequestsByRepository(User user, long repositoryId, int size, int page) { 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 f7bd692..c99d69d 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 @@ -10,7 +10,6 @@ import com.devoops.BaseServiceTest; import com.devoops.client.GitHubClient; -import com.devoops.command.request.RepositoryCreateCommand; import com.devoops.domain.entity.github.repo.GithubRepository; import com.devoops.domain.entity.user.User; import com.devoops.domain.repository.github.repo.GithubRepoDomainRepository; @@ -96,7 +95,10 @@ class Save { user.getId() ); - assertThat(actual.isTracking()).isTrue(); + assertAll( + () -> Mockito.verify(gitHubClient, times(1)).createWebhook(any(), any(), any(), any()), + () -> assertThat(actual.isTracking()).isTrue() + ); } private void mockingGithubClient() { From 10cd9b065243b193a115c74aaaf7b7d7e5b71e63 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 13:30:47 +0900 Subject: [PATCH 28/33] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=EC=97=90=20=EB=8C=80=EC=9D=91=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20optionsBuilder=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=EB=A1=9C=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devoops/client/PrAnalysisClientImpl.java | 20 ++++++++- .../java/com/devoops/config/AiConfig.java | 19 --------- .../devoops/client/ConcurrencyModelTest.java | 42 +++++++++++++++++++ .../com/devoops/PrAnalysisClientImplTest.java | 5 +++ 4 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 gss-client/gss-mcp-client/src/test/java/com/devoops/client/ConcurrencyModelTest.java 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 7b75af6..feeebdf 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 @@ -2,13 +2,17 @@ import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis; 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.OpenAiChatOptions; +import org.springframework.ai.openai.api.ResponseFormat; +import org.springframework.ai.openai.api.ResponseFormat.Type; import org.springframework.stereotype.Component; @Component @@ -17,13 +21,12 @@ public class PrAnalysisClientImpl implements PrAnalysisClient { private final ChatClient chatClient; - private final OpenAiChatOptions.Builder openAiChatOptionsBuilder; private final PromptBuilder promptBuilder; @Override public AnalyzePrResponse analyze(AnalyzePrRequest request) { //option 설정 - OpenAiChatOptions openAiChatOptions = openAiChatOptionsBuilder + OpenAiChatOptions openAiChatOptions = openAiChatBuilder() .model(request.model()) .build(); @@ -39,6 +42,19 @@ public AnalyzePrResponse analyze(AnalyzePrRequest request) { return new AnalyzePrResponse(usage, analysisResult); } + private OpenAiChatOptions.Builder openAiChatBuilder() { + return OpenAiChatOptions.builder() + .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, outputJsonSchema())) + .reasoningEffort("medium") + .temperature(1.0); + + } + + private String outputJsonSchema() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(PrAnalysis.class); + return outputConverter.getJsonSchema(); + } + private ChatResponse callChatResponse(String title, String description, String codeDifference, ChatOptions options) { String userPrompt = promptBuilder.buildUserPrompt(title, description, codeDifference); 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 index ead82c0..6fc1e02 100644 --- 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 @@ -1,32 +1,13 @@ package com.devoops.config; -import com.devoops.dto.response.PrAnalysis; import org.springframework.ai.chat.client.ChatClient; -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.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class AiConfig { - @Bean - public OpenAiChatOptions.Builder openAiChatBuilder() { - return OpenAiChatOptions.builder() - .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, outputJsonSchema())) - .reasoningEffort("medium") - .temperature(1.0); - - } - - public String outputJsonSchema() { - BeanOutputConverter outputConverter = new BeanOutputConverter<>(PrAnalysis.class); - return outputConverter.getJsonSchema(); - } - @Bean public ChatClient chatClient(OpenAiChatModel chatModel) { return ChatClient.create(chatModel); 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 0000000..9a9d8be --- /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-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java index bffd715..ffb935d 100644 --- a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java +++ b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java @@ -3,6 +3,11 @@ 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; From 5a965981b182c7f731c0723408eb8efd76f652d3 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 13:50:42 +0900 Subject: [PATCH 29/33] =?UTF-8?q?refactor:=20=EB=A7=A4=ED=95=91=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gss-client/gss-mcp-client/build.gradle | 1 + .../devoops/client/PrAnalysisClientImpl.java | 5 +++- .../dto/response/AnalyzePrResponse.java | 13 ++------- .../com/devoops/serdes/PrAnalysisMapper.java | 29 +++++++++++++++++++ .../exception/errorcode/ErrorCode.java | 3 +- 5 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 gss-client/gss-mcp-client/src/main/java/com/devoops/serdes/PrAnalysisMapper.java diff --git a/gss-client/gss-mcp-client/build.gradle b/gss-client/gss-mcp-client/build.gradle index 9acb3f3..4870f73 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/PrAnalysisClientImpl.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClientImpl.java index feeebdf..0f18fba 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 @@ -3,6 +3,7 @@ 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; @@ -22,6 +23,7 @@ public class PrAnalysisClientImpl implements PrAnalysisClient { private final ChatClient chatClient; private final PromptBuilder promptBuilder; + private final PrAnalysisMapper prAnalysisMapper; @Override public AnalyzePrResponse analyze(AnalyzePrRequest request) { @@ -39,7 +41,8 @@ public AnalyzePrResponse analyze(AnalyzePrRequest request) { Usage usage = chatresponse.getMetadata().getUsage(); String analysisResult = chatresponse.getResult().getOutput().getText(); - return new AnalyzePrResponse(usage, analysisResult); + PrAnalysis prAnalysis = prAnalysisMapper.mapToPrAnalysis(analysisResult); + return new AnalyzePrResponse(usage, prAnalysis); } private OpenAiChatOptions.Builder openAiChatBuilder() { 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 436dac6..33dba2b 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 @@ -11,21 +11,12 @@ public record AnalyzePrResponse( PrAnalysis prAnalysis ) { - public AnalyzePrResponse(Usage usage, String analysisResult) { + public AnalyzePrResponse(Usage usage, PrAnalysis prAnalysis) { this( usage.getPromptTokens(), usage.getCompletionTokens(), usage.getTotalTokens(), - resolvePrAnalysis(analysisResult) + prAnalysis ); } - - private static PrAnalysis resolvePrAnalysis(String analysisResult) { - ObjectMapper objectMapper = new ObjectMapper(); - try { - return objectMapper.readValue(analysisResult, PrAnalysis.class); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } } 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 0000000..19af851 --- /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-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java b/gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java index 636702e..75740fa 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; From 0a2afed411ad301ef6c760346aad44080195037e Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 13:53:53 +0900 Subject: [PATCH 30/33] =?UTF-8?q?refactor:=20=EB=B3=B5=ED=95=A9=20unique?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/devoops/jpa/entity/analysis/AiChargeEntity.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index fb121ed..b37993b 100644 --- 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 @@ -7,6 +7,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -14,7 +15,9 @@ @Entity @Getter -@Table(name = "ai_charge") +@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 { From afdbdc9f894f19ebf376655783327050436bd8c4 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 14:00:13 +0900 Subject: [PATCH 31/33] =?UTF-8?q?refactor:=20year=EA=B3=BC=20month?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A8=EB=91=90=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/AiChargeRepositoryTest.java | 10 +++++----- .../analysis/AiChargeRepository.java | 4 ++-- .../analysis/AiChargeRepositoryImpl.java | 19 +++++-------------- .../service/pranalysis/PrAnalysisService.java | 6 +++--- 4 files changed, 15 insertions(+), 24 deletions(-) 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 index 9864121..68f416d 100644 --- 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 @@ -24,16 +24,16 @@ class GetByMonth { LocalDate localDate = LocalDate.now(); aiChargeGenerator.generate(localDate.getYear(), localDate.getMonthValue(), charge); - AiCharge actual = chargeRepository.getByMonth(localDate.getMonthValue()); + AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); assertThat(actual.getCharge()).isEqualTo(charge); } @Test void 가져올_요금이_없다면_초기화한다() { - int month = LocalDate.now().getMonthValue(); + LocalDate localDate = LocalDate.now(); - AiCharge actual = chargeRepository.getByMonth(month); + AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); assertThat(actual.getCharge()).isEqualTo(0.0); } @@ -48,9 +48,9 @@ class Update { LocalDate localDate = LocalDate.now(); aiChargeGenerator.generate(localDate.getYear(), localDate.getMonthValue(), charge); - chargeRepository.addCharge(localDate.getMonthValue(), charge); + chargeRepository.addCharge(localDate.getYear(), localDate.getMonthValue(), charge); - AiCharge updatedcharge = chargeRepository.getByMonth(localDate.getMonthValue()); + AiCharge updatedcharge = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); assertThat(updatedcharge.getCharge()).isEqualTo(charge * 2); } } 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 index 83d6c61..e4133e8 100644 --- 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 @@ -4,7 +4,7 @@ public interface AiChargeRepository { - AiCharge getByMonth(int month); + AiCharge getByYearAndMonth(int year, int month); - void addCharge(int month, double charge); + void addCharge(int year, int month, 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 index daea88c..8185da2 100644 --- 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 @@ -3,8 +3,6 @@ import com.devoops.domain.entity.analysis.AiCharge; import com.devoops.domain.repository.analysis.AiChargeRepository; import com.devoops.jpa.entity.analysis.AiChargeEntity; -import java.time.LocalDateTime; -import java.time.ZoneId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -16,24 +14,17 @@ public class AiChargeRepositoryImpl implements AiChargeRepository { private final AiChargeJpaRepository chargeJpaRepository; @Override - public AiCharge getByMonth(int month) { - ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); - LocalDateTime now = LocalDateTime.now(seoulZoneId); - int todayYear = now.getYear(); - int todayMonth = now.getMonthValue(); - return chargeJpaRepository.findByYearAndMonth(todayYear, todayMonth) + public AiCharge getByYearAndMonth(int year, int month) { + return chargeJpaRepository.findByYearAndMonth(year, month) .orElseGet(() -> { - AiCharge initializeCharge = new AiCharge(todayYear, todayMonth, 0); + AiCharge initializeCharge = new AiCharge(year, month, 0); return chargeJpaRepository.save(AiChargeEntity.from(initializeCharge)); }).toDomainEntity(); } @Override @Transactional - public void addCharge(int month, double charge) { - ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); - LocalDateTime now = LocalDateTime.now(seoulZoneId); - int todayYear = now.getYear(); - chargeJpaRepository.updateChargeById(todayYear, month, charge); + public void addCharge(int year, int month, double charge) { + chargeJpaRepository.updateChargeById(year, month, charge); } } 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 98cd7a2..e6e9aa5 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 @@ -24,8 +24,8 @@ public class PrAnalysisService { public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) { String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); - int month = LocalDate.now(seoulZoneId).getMonthValue(); - AiCharge aiCharge = chargeRepository.getByMonth(month); + LocalDate today = LocalDate.now(seoulZoneId); + AiCharge aiCharge = chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue()); OpenAiModel aiModel = OpenAiModel.getModelByUsage(aiCharge.getCharge()); AdaptedAnalyzePrResponse result = prAnalysisAdapter.analyze( @@ -36,7 +36,7 @@ public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest reques ); double consumedCharge = aiModel.getCharge(result.promptTokens(), result.completionTokens()); - chargeRepository.addCharge(month, consumedCharge); + chargeRepository.addCharge(today.getYear(), today.getMonthValue(), consumedCharge); return result; } } From 46841b763432b183c727e0a5097def53556f38e0 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 24 Aug 2025 14:07:33 +0900 Subject: [PATCH 32/33] =?UTF-8?q?refactor:=20=EB=B6=80=EB=8F=99=EC=86=8C?= =?UTF-8?q?=EC=88=98=EC=A0=90=20=EC=97=90=EB=9F=AC=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit double > BigDecimal --- .../src/main/java/com/devoops/util/CurrencyUtil.java | 11 +++++++++-- .../devoops/jpa/entity/analysis/AiChargeEntity.java | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java b/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java index 6560fa7..923793a 100644 --- a/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java +++ b/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java @@ -1,5 +1,7 @@ package com.devoops.util; +import java.math.BigDecimal; +import java.math.RoundingMode; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -7,14 +9,19 @@ public class CurrencyUtil { // 25-08-21 기준 - private static final double CURRENCY_RATE = 1397.84; + 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) { - return usd * CURRENCY_RATE; + 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/jpa/entity/analysis/AiChargeEntity.java b/gss-domain/src/main/java/com/devoops/jpa/entity/analysis/AiChargeEntity.java index b37993b..cb50b05 100644 --- 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 @@ -32,6 +32,7 @@ public class AiChargeEntity { @Column(name = "charge_month") private int month; + @Column(precision = 10, scale = 2) private double charge; public static AiChargeEntity from(AiCharge aiCharge) { From 73afb58a365d83ad13037b5724d71eda0233cb7a Mon Sep 17 00:00:00 2001 From: coli Date: Mon, 25 Aug 2025 22:21:35 +0900 Subject: [PATCH 33/33] =?UTF-8?q?refactor:=20=EB=B6=80=EB=8F=99=EC=86=8C?= =?UTF-8?q?=EC=88=98=EC=A0=90=20=EC=97=90=EB=9F=AC=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit double > BigDecimal --- .../repository/analysis/AiChargeRepositoryTest.java | 6 +++--- .../devoops/domain/entity/analysis/AiCharge.java | 3 ++- .../devoops/domain/entity/analysis/OpenAiModel.java | 7 ++++--- .../devoops/jpa/entity/analysis/AiChargeEntity.java | 3 ++- .../repository/analysis/AiChargeRepositoryImpl.java | 3 ++- .../domain/entity/analysis/OpenAiModelTest.java | 13 +++++++------ .../com/devoops/generator/AiChargeGenerator.java | 3 ++- 7 files changed, 22 insertions(+), 16 deletions(-) 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 index 68f416d..a5c41dd 100644 --- 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 @@ -26,7 +26,7 @@ class GetByMonth { AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); - assertThat(actual.getCharge()).isEqualTo(charge); + assertThat(actual.getCharge().doubleValue()).isEqualTo(charge); } @Test @@ -35,7 +35,7 @@ class GetByMonth { AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); - assertThat(actual.getCharge()).isEqualTo(0.0); + assertThat(actual.getCharge().doubleValue()).isEqualTo(0.0); } } @@ -51,7 +51,7 @@ class Update { chargeRepository.addCharge(localDate.getYear(), localDate.getMonthValue(), charge); AiCharge updatedcharge = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); - assertThat(updatedcharge.getCharge()).isEqualTo(charge * 2); + assertThat(updatedcharge.getCharge().doubleValue()).isEqualTo(charge * 2); } } } 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 index 5a68584..bd26018 100644 --- 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 @@ -1,5 +1,6 @@ package com.devoops.domain.entity.analysis; +import java.math.BigDecimal; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -9,5 +10,5 @@ public class AiCharge { private final int year; private final int month; - private final double charge; + 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 index f8ed033..b316425 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 @@ -1,6 +1,7 @@ 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; @@ -20,10 +21,10 @@ public enum OpenAiModel { private final double inputTokenCharge; //달러 private final double outputTokenCharge; //달러 - public static OpenAiModel getModelByUsage(double currentUsageWon) { + public static OpenAiModel getModelByUsage(BigDecimal currentUsageWon) { return Stream.of(values()) - .filter(model -> model.moneyUnderCriteria <= currentUsageWon - && model.moneyUpperCriteria >= currentUsageWon) + .filter(model -> model.moneyUnderCriteria <= currentUsageWon.doubleValue() + && model.moneyUpperCriteria >= currentUsageWon.doubleValue()) .findAny() .orElse(GPT_5_NANO); } 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 index cb50b05..8ff227e 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -33,7 +34,7 @@ public class AiChargeEntity { private int month; @Column(precision = 10, scale = 2) - private double charge; + private BigDecimal charge; public static AiChargeEntity from(AiCharge aiCharge) { return new AiChargeEntity( 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 index 8185da2..1403f44 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -17,7 +18,7 @@ public class AiChargeRepositoryImpl implements AiChargeRepository { public AiCharge getByYearAndMonth(int year, int month) { return chargeJpaRepository.findByYearAndMonth(year, month) .orElseGet(() -> { - AiCharge initializeCharge = new AiCharge(year, month, 0); + AiCharge initializeCharge = new AiCharge(year, month, BigDecimal.ZERO); return chargeJpaRepository.save(AiChargeEntity.from(initializeCharge)); }).toDomainEntity(); } 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 index 283fd80..bf2980f 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -13,8 +14,8 @@ class ModelSelection { @Test void GPT5_범위에서_선택된다() { - int lowerBound = OpenAiModel.GPT_5.getMoneyUnderCriteria(); - int upperBound = OpenAiModel.GPT_5.getMoneyUpperCriteria(); + 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), @@ -24,8 +25,8 @@ class ModelSelection { @Test void GPT5_MINI_범위에서_선택된다() { - int lowerBound = OpenAiModel.GPT_5_MINI.getMoneyUnderCriteria(); - int upperBound = OpenAiModel.GPT_5_MINI.getMoneyUpperCriteria(); + 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), @@ -35,8 +36,8 @@ class ModelSelection { @Test void GPT5_NANO_범위에서_선택된다() { - int lowerBound = OpenAiModel.GPT_5_NANO.getMoneyUnderCriteria(); - int upperBound = OpenAiModel.GPT_5_NANO.getMoneyUpperCriteria(); + 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), diff --git a/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java b/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java index 7dd5a0a..aab4f4b 100644 --- a/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java +++ b/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java @@ -3,6 +3,7 @@ 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; @@ -13,7 +14,7 @@ public class AiChargeGenerator { private AiChargeJpaRepository repository; public AiCharge generate(int year, int month, double charge) { - AiCharge aiCharge = new AiCharge(year, month, charge); + AiCharge aiCharge = new AiCharge(year, month, BigDecimal.valueOf(charge)); return repository.save(AiChargeEntity.from(aiCharge)) .toDomainEntity(); }