Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0ae5cde
chore: mcp client 로직을 코드 기반으로 수정
coli-geonwoo Aug 18, 2025
510fedc
chore: chatclient config 추가
coli-geonwoo Aug 18, 2025
f7bdda1
chore: schema 생성을 위한 required 추가
coli-geonwoo Aug 18, 2025
25891a2
chore: 프롬프트 수정
coli-geonwoo Aug 18, 2025
6f2d036
chore: 프롬프트 수정
coli-geonwoo Aug 20, 2025
612632c
chore: AI PR 분석로직 수정
coli-geonwoo Aug 20, 2025
b455cc6
feat: Aiconfig로 옵션 설정
coli-geonwoo Aug 20, 2025
4ec1670
feat: PromptBuilder 구현
coli-geonwoo Aug 20, 2025
1804e2d
feat: PR 분석 토큰을 반환하도록 수정
coli-geonwoo Aug 20, 2025
e29dd6f
feat: event 발행 시 AiModel를 포함하도록 수정
coli-geonwoo Aug 20, 2025
fa9bd51
feat: PrAnalysisService에서 분석하도록 수정
coli-geonwoo Aug 20, 2025
0f3bec9
feat: model 로드벨런싱 기준 설정
coli-geonwoo Aug 20, 2025
984a8fa
feat: 환율계산 유틸 구현
coli-geonwoo Aug 20, 2025
a118668
feat: 비용 계산 로직 구현
coli-geonwoo Aug 20, 2025
784aa28
feat: 비용 관련 도메인 로직 작성
coli-geonwoo Aug 20, 2025
c889cf4
feat: 요금에 따른 model 선택 정책 도입
coli-geonwoo Aug 20, 2025
eb88a59
feat: 요금에 기준점 변경
coli-geonwoo Aug 20, 2025
e8d5d70
feat: 요금 엔티티 구현
coli-geonwoo Aug 20, 2025
f5e495b
refactor: 요금 업데이트 로직 변경
coli-geonwoo Aug 20, 2025
2112956
test: 모델 selection 테스트
coli-geonwoo Aug 20, 2025
61bd043
refactor: zoneId 추가
coli-geonwoo Aug 20, 2025
702594e
refactor: unique 제약조건 제거
coli-geonwoo Aug 20, 2025
934adb8
fix: 중복 저장 문제 로직 변경
coli-geonwoo Aug 23, 2025
b7265e0
chore: 프롬프트 변경
coli-geonwoo Aug 23, 2025
82415d0
test: ai비용 업데이트 테스트 작성
coli-geonwoo Aug 23, 2025
cc9c21f
test: ai호출 테스트 disabled
coli-geonwoo Aug 23, 2025
fa7db45
fix: 중복 웹훅 등록 문제 해결
coli-geonwoo Aug 24, 2025
10cd9b0
fix: 동시성 이슈에 대응하기 위해 optionsBuilder를 인스턴스 객체로 선언
coli-geonwoo Aug 24, 2025
5a96598
refactor: 매핑로직 분리
coli-geonwoo Aug 24, 2025
0a2afed
refactor: 복합 unique 제약조건 추가
coli-geonwoo Aug 24, 2025
afdbdc9
refactor: year과 month를 모두 파라미터로 받도록 수정
coli-geonwoo Aug 24, 2025
46841b7
refactor: 부동소수점 에러에 대응하기 위한 타입변경
coli-geonwoo Aug 24, 2025
9f8c1f2
Merge branch 'develop' into refactor/#72
coli-geonwoo Aug 24, 2025
73afb58
refactor: 부동소수점 에러에 대응하기 위한 타입변경
coli-geonwoo Aug 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<GithubRepository> alreadyRegisteredRepo = repositoryService.findByUserAndExternalId(user, externalId);

if(alreadyRegisteredRepo.isPresent()) {
return reTrackingOrThrowException(user, alreadyRegisteredRepo.get());
}

RepositoryCreateCommand createCommand = new RepositoryCreateCommand(
user.getId(),
repositoryInfo.name(),
Expand All @@ -57,6 +65,13 @@ private GithubRepository saveRepository(GithubRepoUrl url, User user) {
return repositoryService.save(createCommand);
}

private GithubRepository reTrackingOrThrowException(User user, GithubRepository registeredRepo) {
if(registeredRepo.isTracking()) {
throw new GssException(ErrorCode.ALREADY_SAVED_REPOSITORY);
}
return repositoryService.reTracking(user, registeredRepo.getExternalId());
}

public PullRequests findAllPullRequestsByRepository(User user, long repositoryId, int size, int page) {
return repositoryService.getPullRequestsByRepository(user, repositoryId, size, page);
}
Expand Down
25 changes: 25 additions & 0 deletions gss-api-app/src/test/java/com/devoops/BaseRepositoryTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions gss-api-app/src/test/java/com/devoops/BaseServiceTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -39,5 +40,8 @@ public abstract class BaseServiceTest {

@Autowired
protected WebhookGenerator webhookGenerator;

@Autowired
protected AiChargeGenerator aiChargeGenerator;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.devoops.repository.analysis;

import static org.assertj.core.api.Assertions.assertThat;

import com.devoops.BaseServiceTest;
import com.devoops.domain.entity.analysis.AiCharge;
import com.devoops.domain.repository.analysis.AiChargeRepository;
import java.time.LocalDate;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

class AiChargeRepositoryTest extends BaseServiceTest {

@Autowired
private AiChargeRepository chargeRepository;

@Nested
class GetByMonth {

@Test
void 월에_해당하는_요금을_가져온다() {
double charge = 1500.0;
LocalDate localDate = LocalDate.now();
aiChargeGenerator.generate(localDate.getYear(), localDate.getMonthValue(), charge);

AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue());

assertThat(actual.getCharge().doubleValue()).isEqualTo(charge);
}

@Test
void 가져올_요금이_없다면_초기화한다() {
LocalDate localDate = LocalDate.now();

AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue());

assertThat(actual.getCharge().doubleValue()).isEqualTo(0.0);
}
}

@Nested
class Update {

@Test
void 요금을_업데이트_할_수_있다() {
double charge = 1500.0;
LocalDate localDate = LocalDate.now();
aiChargeGenerator.generate(localDate.getYear(), localDate.getMonthValue(), charge);

chargeRepository.addCharge(localDate.getYear(), localDate.getMonthValue(), charge);

AiCharge updatedcharge = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue());
assertThat(updatedcharge.getCharge().doubleValue()).isEqualTo(charge * 2);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ class Save {
.hasMessage(ErrorCode.ALREADY_SAVED_REPOSITORY.getMessage());
}

@Test
void 레포지토리를_재연결_할_수_있다() {
User user = userGenerator.generate("김건우");
GithubRepository unTrackingRepo = repoGenerator.generate(user, "연결 끊긴 레포지토리", 123L, false);
RepositorySaveRequest request = new RepositorySaveRequest("https://github.com/octocat/Hello-World");
mockingGithubClient();

GithubRepository reTrackingRepository = repositoryFacadeService.save(request, user);

GithubRepository actual = githubRepoDomainRepository.findByIdAndUserId(
unTrackingRepo.getId(),
user.getId()
);

assertAll(
() -> Mockito.verify(gitHubClient, times(1)).createWebhook(any(), any(), any(), any()),
() -> assertThat(actual.isTracking()).isTrue()
);
}

private void mockingGithubClient() {
GithubRepoInfoResponse mockResponse = new GithubRepoInfoResponse(123, "testName", "testUrl",
new OwnerResponse("김건우"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions gss-client/gss-mcp-client/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
}

dependencies {
implementation project(":gss-common")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

implementation vs api 재검토 — gss-common 타입이 공개 API에 노출된다면 api가 맞습니다

java-library를 사용하는 경우, gss-common의 타입(GssException, ErrorCode, DTO 등)이 이 모듈의 public 메서드 시그니처/예외에 등장하면 api project(":gss-common")로 노출해야 합니다. 그렇지 않으면 소비자 모듈에서 클래스패스 누락 문제가 날 수 있습니다.

다음 스크립트로 공개 API 노출 여부를 확인해 주세요(리포지토리 루트에서 실행).


🏁 Script executed:

#!/bin/bash
# gss-mcp-client의 public 시그니처에 gss-common 타입이 보이는지 점검
rg -n --type=java -C2 'public (class|interface|enum)|public [^=]*\(' gss-client/gss-mcp-client/src/main/java \
  | rg -n 'GssException|ErrorCode|com\.devoops\.(exception|util|dto)\.'

Length of output: 441


gss-mcp-client의 public 메서드 시그니처에 AnalyzePrResponse가 사용되는지 확인하기 위해 파일 상단을 출력합니다.


🏁 Script executed:

#!/bin/bash
# PrAnalysisClient.java 상위 200줄 출력
sed -n '1,200p' gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClient.java

Length of output: 323


gss-mcp-client: gss-common 의존성 implementationapi로 변경 필요

PrAnalysisClient 인터페이스가 AnalyzePrRequest/AnalyzePrResponse (com.devoops.dto.*) 타입을 public API 시그니처에 노출하고 있으므로, gss-common 모듈을 api로 선언해야 소비자 모듈에서 클래스패스 누락 없이 정상 컴파일/실행이 가능합니다.

– 파일: gss-client/gss-mcp-client/build.gradle
– 변경 위치: 6행

제안하는 수정:

-dependencies {
-    implementation project(":gss-common")
+dependencies {
+    api project(":gss-common")
     // …
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
implementation project(":gss-common")
// gss-client/gss-mcp-client/build.gradle
dependencies {
api project(":gss-common")
//
}
🤖 Prompt for AI Agents
In gss-client/gss-mcp-client/build.gradle around line 6, the dependency on
gss-common is declared as implementation which hides its types from consumers;
change the declaration to use api project(":gss-common") so the PrAnalysisClient
public API types (AnalyzePrRequest/AnalyzePrResponse) are exposed on the
consumer classpath and downstream modules can compile and run without missing
classes.

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

Expand Down
Original file line number Diff line number Diff line change
@@ -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);

}
Original file line number Diff line number Diff line change
@@ -1,44 +1,72 @@
package com.devoops.client;

import com.devoops.dto.request.AnalyzePrRequest;
import com.devoops.dto.response.AnalyzePrResponse;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.devoops.dto.response.PrAnalysis;
import com.devoops.serdes.PrAnalysisMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.ResponseFormat;
import org.springframework.ai.openai.api.ResponseFormat.Type;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class PrAnalysisClientImpl implements PrAnalysisClient {

@Value("${dev-oops.github-pr-analysis.prompt}")
private String promptTemplate;

private final ChatModel chatModel;
private final ObjectMapper objectMapper;
private final ChatClient chatClient;
private final PromptBuilder promptBuilder;
private final PrAnalysisMapper prAnalysisMapper;

@Override
public AnalyzePrResponse analyze(String title, String description, String diff) {
String prompt = buildPrompt(title, description, diff);
// log.info("prompt = {}", prompt);
String content = chatModel.call(prompt);
return parseResponse(content);
public AnalyzePrResponse analyze(AnalyzePrRequest request) {
//option 설정
OpenAiChatOptions openAiChatOptions = openAiChatBuilder()
.model(request.model())
.build();

ChatResponse chatresponse = callChatResponse(
request.title(),
request.description(),
request.codeDifference(),
openAiChatOptions
);

Usage usage = chatresponse.getMetadata().getUsage();
String analysisResult = chatresponse.getResult().getOutput().getText();
PrAnalysis prAnalysis = prAnalysisMapper.mapToPrAnalysis(analysisResult);
return new AnalyzePrResponse(usage, prAnalysis);
}

private OpenAiChatOptions.Builder openAiChatBuilder() {
return OpenAiChatOptions.builder()
.responseFormat(new ResponseFormat(Type.JSON_SCHEMA, outputJsonSchema()))
.reasoningEffort("medium")
.temperature(1.0);

}

private String buildPrompt(String title, String description, String diff) {
return String.format(promptTemplate, title, description, diff);
private String outputJsonSchema() {
BeanOutputConverter<PrAnalysis> outputConverter = new BeanOutputConverter<>(PrAnalysis.class);
return outputConverter.getJsonSchema();
}

private AnalyzePrResponse parseResponse(String content) {
try {
return objectMapper.readValue(content, new TypeReference<>() {
});
} catch (Exception e) {
log.error("AI 응답 파싱 실패. 응답 내용: {}", content, e);
throw new IllegalArgumentException("AI 응답 파싱 중 오류 발생", e);
}
private ChatResponse callChatResponse(String title, String description, String codeDifference,
ChatOptions options) {
String userPrompt = promptBuilder.buildUserPrompt(title, description, codeDifference);
String systemPrompt = promptBuilder.buildSystemPrompt();
return chatClient.prompt()
.options(options)
.system(systemPrompt)
.user(userPrompt)
.call()
.chatResponse();
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.devoops.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

@Bean
public ChatClient chatClient(OpenAiChatModel chatModel) {
return ChatClient.create(chatModel);
}
}
Loading
Loading