Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,10 +1,13 @@
package com.devoops.repository.analysis;

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

import com.devoops.BaseServiceTest;
import com.devoops.domain.entity.analysis.AiCharge;
import com.devoops.domain.repository.analysis.AiChargeRepository;
import com.devoops.exception.custom.GssException;
import com.devoops.exception.errorcode.ErrorCode;
import java.time.LocalDate;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand All @@ -30,13 +33,14 @@ class GetByMonth {
}

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

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

assertThat(actual.getCharge().doubleValue()).isEqualTo(0.0);
assertThatThrownBy(() -> chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()))
.isInstanceOf(GssException.class)
.hasMessage(ErrorCode.AI_CHARGE_NOT_FOUND.getMessage());
}
Comment on lines +36 to 42
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

테스트 안정성: 날짜 고정 및 에러코드까지 검증 권장

  • LocalDate.now()는 테스트 환경/시차/사전 데이터에 따라 플래키할 수 있습니다. 고정 일자를 사용하세요.
  • 가능하다면 GssException의 ErrorCode도 함께 단정해 의도를 더 명확히 하세요.
-            LocalDate localDate = LocalDate.now();
+            LocalDate localDate = LocalDate.of(2099, 12, 1); // 존재하지 않을 것이 보장되는 미래 날짜
-
-            assertThatThrownBy(() -> chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()))
-                    .isInstanceOf(GssException.class)
-                    .hasMessage(ErrorCode.AI_CHARGE_NOT_FOUND.getMessage());
+            assertThatThrownBy(() -> chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()))
+                    .isInstanceOf(GssException.class)
+                    .hasMessage(ErrorCode.AI_CHARGE_NOT_FOUND.getMessage());
+            // (선택) ErrorCode 필드도 검증하려면 GssException API 확인 필요
+            // .extracting("errorCode").isEqualTo(ErrorCode.AI_CHARGE_NOT_FOUND);

검증 스크립트: GssException이 errorCode 게터를 노출하는지 확인


🏁 Script executed:

#!/bin/bash
rg -nP -C2 'class\s+GssException\b'
rg -nP -C2 '\bgetErrorCode\s*\('

Length of output: 1175


테스트 안정성 강화 및 (선택) 에러코드 검증

  • LocalDate.now() 대신 재현 가능한 고정 날짜 사용(e.g. LocalDate.of(2099, 12, 1))
  • (선택) assertThatThrownBy(...) .extracting("errorCode") .isEqualTo(ErrorCode.AI_CHARGE_NOT_FOUND)
    → GssException에 getErrorCode()가 Lombok @Getter로 생성되어 있어 검증 가능
🤖 Prompt for AI Agents
In
gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java
around lines 36-42, the test uses LocalDate.now() making it non-deterministic;
replace LocalDate.now() with a fixed reproducible date such as
LocalDate.of(2099, 12, 1) so the test is stable, and optionally strengthen the
assertion by extracting/validating the exception's error code (e.g., assert that
the thrown GssException has errorCode equal to ErrorCode.AI_CHARGE_NOT_FOUND)
using the existing getErrorCode() getter.


}

@Nested
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ public enum ErrorCode {
REDIS_SUBSCRIBE_ERROR(500, "레디스 이벤트 수신 과정에서 문제가 생겼습니다"),
GITHUB_CLIENT_ERROR(500, "깃허브 클라이언트 소통과정에 문제가 발생했습니다"),
AI_RESPONSE_PARSING_ERROR(500, "AI로부터 온 질문 생성을 파싱하는 과정에 오류가 발생했습니다"),
AI_CREATE_QUESTION_ERROR(500, "AI 질문 생성과정에 오류가 발생했습니다")
AI_CREATE_QUESTION_ERROR(500, "AI 질문 생성과정에 오류가 발생했습니다"),
AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 없습니다.")
;
Comment on lines +43 to 45
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

오류 메시지 오타 수정 및 상태코드 검토

  • 사용자 노출 문구 “찾을 없습니다”는 비문입니다. “찾을 수 없습니다”로 수정하세요.
  • 도메인 리소스 부재는 500보다는 404가 더 적합합니다(내부 예외라도 의미적으로 Not Found). 맵핑 정책에 따라 결정하시되, 최소한 문구는 즉시 수정 필요.

문구 수정안:

-    AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 없습니다.")
+    AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 수 없습니다.")

(선택) 상태코드 정합성 향상:

-    AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 수 없습니다.")
+    AI_CHARGE_NOT_FOUND(404, "당월 AI 비용을 찾을 수 없습니다.")
🤖 Prompt for AI Agents
In gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java
around lines 43 to 45, the enum entry AI_CHARGE_NOT_FOUND contains a typo in the
user-facing message ("찾을 없습니다") and an inappropriate 500 status for a missing
resource; update the message to "찾을 수 없습니다" and, depending on your error-mapping
policy, change the status code from 500 to 404 for AI_CHARGE_NOT_FOUND (or
document why 500 is retained) so the enum reflects correct wording and semantic
status.


private final int statusCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ public interface AiChargeRepository {
AiCharge getByYearAndMonth(int year, int month);

void addCharge(int year, int month, double charge);

AiCharge save(AiCharge charge);
}
Comment on lines 9 to 12
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

addCharge 결과값 반환 및 save 계약 명시화

  • addCharge가 업데이트 성공 여부를 알려주지 않습니다. 0행 업데이트 시 NotFound 예외 매핑을 위해 업데이트 카운트를 반환하도록 변경을 권장합니다.
  • save는 “신규 생성”인지 “업서트”인지 계약을 자바독으로 명확히 해주세요(동시성 중복 생성 시 동작 포함).
-    void addCharge(int year, int month, double charge);
+    int addCharge(int year, int month, double charge);
 
-    AiCharge save(AiCharge charge);
+    /**
+     * 지정 연/월의 AiCharge를 저장한다.
+     * 동시성으로 인한 중복키 충돌 발생 시의 처리(무시/조회 재시도/예외)를 명확히 보장해야 한다.
+     */
+    AiCharge save(AiCharge charge);

검증 스크립트(리포지토리 구현이 업데이트 카운트를 반환하는지 확인):


🏁 Script executed:

#!/bin/bash
# updateChargeById 시그니처와 @Modifying 여부 확인
rg -nP -C3 'interface\s+AiChargeJpaRepository\b'
rg -nP -C2 '\bupdateChargeById\s*\('
rg -nP -C2 '@Modifying'

Length of output: 4562


addCharge 반환값(int) 및 save 계약 자바독 명시화 필요

  • AiChargeJpaRepository.updateChargeById를 int 반환으로 변경하고, 도메인 레포지토리 addChargeint 반환으로 수정하세요(업데이트 건수 반환).
  • save 메서드는 신규 생성(insert) vs. 기존 수정(upsert) 및 동시성 키 충돌 처리(무시/재시도/예외 등)를 JavaDoc으로 명시하세요.
// gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java
-    void addCharge(int year, int month, double charge);
+    int addCharge(int year, int month, double charge);

-    AiCharge save(AiCharge charge);
+    /**
+     * 지정 연/월의 AiCharge를 저장합니다.
+     * 신규 생성(insert) 또는 기존 수정(upsert) 동작을 보장하며,
+     * 동시성 중복 키 충돌 시(무시/조회 재시도/예외 처리) 동작을 문서화해야 합니다.
+     */
+    AiCharge save(AiCharge charge);
📝 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
void addCharge(int year, int month, double charge);
AiCharge save(AiCharge charge);
}
// gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java
/**
* 지정된 /월의 요금을 업데이트하며, 영향을 받은 수를 반환합니다.
*
* @param year 연도 (: 2025)
* @param month (1-12)
* @param charge 갱신할 요금
* @return 업데이트된 레코드
*/
int addCharge(int year, int month, double charge);
/**
* 지정 /월의 AiCharge를 저장합니다.
* 신규 생성(insert) 또는 기존 수정(upsert) 동작을 보장하며,
* 동시성 중복 충돌 (무시/재조회 재시도/예외 처리) 동작을 문서화해야 합니다.
*
* @param charge 저장할 AiCharge 엔티티
* @return 저장된(또는 갱신된) AiCharge 엔티티
*/
AiCharge save(AiCharge charge);
}
🤖 Prompt for AI Agents
In
gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java
around lines 9-12, change the addCharge signature to return int (number of
updated rows) and update the underlying AiChargeJpaRepository.updateChargeById
to return int as well; additionally add a concise JavaDoc to the save(AiCharge)
method that specifies whether it performs an insert vs update (upsert) and
document the concurrency/key-conflict behavior (e.g., whether conflicts are
ignored, retried, or cause an exception) and any expected transactional
guarantees so callers know how to handle failures.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.devoops.domain.entity.analysis.AiCharge;
import com.devoops.domain.repository.analysis.AiChargeRepository;
import com.devoops.exception.custom.GssException;
import com.devoops.exception.errorcode.ErrorCode;
import com.devoops.jpa.entity.analysis.AiChargeEntity;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
Expand All @@ -17,15 +19,20 @@ public class AiChargeRepositoryImpl implements AiChargeRepository {
@Override
public AiCharge getByYearAndMonth(int year, int month) {
return chargeJpaRepository.findByYearAndMonth(year, month)
.orElseGet(() -> {
AiCharge initializeCharge = new AiCharge(year, month, BigDecimal.ZERO);
return chargeJpaRepository.save(AiChargeEntity.from(initializeCharge));
}).toDomainEntity();
.orElseThrow(() -> new GssException(ErrorCode.AI_CHARGE_NOT_FOUND))
.toDomainEntity();
}

@Override
@Transactional
public void addCharge(int year, int month, double charge) {
chargeJpaRepository.updateChargeById(year, month, charge);
}

@Override
@Transactional
public AiCharge save(AiCharge charge) {
return chargeJpaRepository.save(AiChargeEntity.from(charge))
.toDomainEntity();
}
Comment on lines +32 to +37
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

save 동시성 중복키 대비 — 멱등 저장으로 보강

다중 노드/스케줄러 중복 실행 시 unique(year, month) 충돌 가능. DataIntegrityViolationException을 잡아 조회 재시도로 멱등화하세요.

 import com.devoops.domain.entity.analysis.AiCharge;
 import com.devoops.domain.repository.analysis.AiChargeRepository;
 import com.devoops.exception.custom.GssException;
 import com.devoops.exception.errorcode.ErrorCode;
 import com.devoops.jpa.entity.analysis.AiChargeEntity;
 import java.math.BigDecimal;
+import org.springframework.dao.DataIntegrityViolationException;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Repository;
 import org.springframework.transaction.annotation.Transactional;
@@
     @Override
     @Transactional
     public AiCharge save(AiCharge charge) {
-        return chargeJpaRepository.save(AiChargeEntity.from(charge))
-                .toDomainEntity();
+        try {
+            return chargeJpaRepository.save(AiChargeEntity.from(charge))
+                    .toDomainEntity();
+        } catch (DataIntegrityViolationException e) {
+            // 동시성으로 이미 생성된 경우: 조회로 멱등 보장
+            return chargeJpaRepository.findByYearAndMonth(charge.getYear(), charge.getMonth())
+                    .orElseThrow(() -> new GssException(ErrorCode.AI_CHARGE_NOT_FOUND))
+                    .toDomainEntity();
+        }
     }

(운영 제안) 스케줄러 단일 실행을 위해 ShedLock 등을 사용하면 DB 충돌 자체를 크게 줄일 수 있습니다.

📝 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
@Override
@Transactional
public AiCharge save(AiCharge charge) {
return chargeJpaRepository.save(AiChargeEntity.from(charge))
.toDomainEntity();
}
++ b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java
@@
import com.devoops.jpa.entity.analysis.AiChargeEntity;
import java.math.BigDecimal;
import org.springframework.dao.DataIntegrityViolationException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@@
@Override
@Transactional
public AiCharge save(AiCharge charge) {
- return chargeJpaRepository.save(AiChargeEntity.from(charge))
try {
return chargeJpaRepository.save(AiChargeEntity.from(charge))
.toDomainEntity();
} catch (DataIntegrityViolationException e) {
// 동시성으로 이미 생성된 경우: 조회로 멱등 보장
return chargeJpaRepository.findByYearAndMonth(charge.getYear(), charge.getMonth())
.orElseThrow(() -> new GssException(ErrorCode.AI_CHARGE_NOT_FOUND))
.toDomainEntity();
}
}
🤖 Prompt for AI Agents
In
gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java
around lines 32-37, the save method can throw a DataIntegrityViolationException
under concurrent inserts for the same unique(year, month); update the method to
catch DataIntegrityViolationException, and in the catch perform a lookup by year
and month (via the JPA repository) and return the existing entity’s domain
object to achieve idempotent save (optionally retry the lookup a couple times if
necessary); keep the method transactional and ensure any thrown exception types
are handled/translated appropriately.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.devoops.service.pranalysis;

import com.devoops.domain.entity.analysis.AiCharge;
import com.devoops.domain.repository.analysis.AiChargeRepository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Comment on lines +9 to +11
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

initializeNextMonth 멱등/동시성 안전성 부족 — 고유키 충돌 흡수 및 재조회

현재는 무조건 save하여 유니크(year, month) 충돌 시 예외가 납니다. 분산 스케줄/동시 트리거에 취약하므로 충돌은 정상 시나리오로 간주하고 재조회로 멱등화하세요.

-import org.springframework.stereotype.Service;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
@@
-    public AiCharge initializeNextMonth() {
+    @Transactional
+    public AiCharge initializeNextMonth() {
         LocalDate now = LocalDate.now(SEOUL_ZONE_ID);
         YearMonth nextMonth = YearMonth.from(now).plusMonths(1);
         AiCharge aiCharge = new AiCharge(nextMonth.getYear(), nextMonth.getMonthValue(), BigDecimal.ZERO);
-        return chargeRepository.save(aiCharge);
+        try {
+            return chargeRepository.save(aiCharge);
+        } catch (DataIntegrityViolationException e) {
+            // 이미 다른 쓰레드/노드가 생성함: 재조회로 멱등 보장
+            return chargeRepository.getByYearAndMonth(nextMonth.getYear(), nextMonth.getMonthValue());
+        }
     }

Also applies to: 20-25

🤖 Prompt for AI Agents
In gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java
around lines 9-11 and 20-25, the initializeNextMonth method currently
unconditionally calls save and propagates unique (year,month) constraint
exceptions; change it to be idempotent and concurrency-safe by catching the
persistence exception thrown on unique-key conflict (e.g.,
DataIntegrityViolationException / DuplicateKeyException), then re-query the
repository for the record by year and month and return that existing entity
instead of failing; ensure the save call remains inside a transaction where
appropriate and prefer repository.findByYearAndMonth (or equivalent) for the
re-fetch so concurrent triggers simply return the already-created row.

@Service
@RequiredArgsConstructor
public class AiChargeService {

private static final ZoneId SEOUL_ZONE_ID = ZoneId.of("Asia/Seoul");

private final AiChargeRepository chargeRepository;

public AiCharge initializeNextMonth() {
LocalDate now = LocalDate.now(SEOUL_ZONE_ID);
YearMonth nextMonth = YearMonth.from(now).plusMonths(1);
AiCharge aiCharge = new AiCharge(nextMonth.getYear(), nextMonth.getMonthValue(), BigDecimal.ZERO);
return chargeRepository.save(aiCharge);
}

public AiCharge getMonthlyCharge() {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
}
Comment on lines +27 to +30
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

미존재 월 예외 전파는 서비스 책임 회피 — 안전한 지연 생성으로 전환

운영 중 스케줄 누락/초기 데이터 부재 가능성을 고려해 서비스 레이어에서 “조회 또는 생성”을 보장하세요.

-    public AiCharge getMonthlyCharge() {
-        LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
-        return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
-    }
+    public AiCharge getOrCreateMonthlyCharge() {
+        LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
+        try {
+            return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
+        } catch (com.devoops.exception.GssException ex) {
+            if (ex.getErrorCode() != com.devoops.exception.errorcode.ErrorCode.AI_CHARGE_NOT_FOUND) throw ex;
+            AiCharge newCharge = new AiCharge(today.getYear(), today.getMonthValue(), BigDecimal.ZERO);
+            try {
+                return chargeRepository.save(newCharge);
+            } catch (org.springframework.dao.DataIntegrityViolationException e) {
+                return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
+            }
+        }
+    }

사용처(PrAnalysisFacadeService)도 getOrCreateMonthlyCharge()로 교체해 주세요.

📝 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
public AiCharge getMonthlyCharge() {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
}
// 기존 getMonthlyCharge() 대신 아래 메서드를 사용해 조회 또는 생성을 보장합니다.
public AiCharge getOrCreateMonthlyCharge() {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
try {
// 이미 존재하면 바로 반환
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
} catch (com.devoops.exception.GssException ex) {
// 예상된 "월별 요금 없음" 예외가 아니면 그대로 재던짐
if (ex.getErrorCode() != com.devoops.exception.errorcode.ErrorCode.AI_CHARGE_NOT_FOUND) {
throw ex;
}
// 없으면 지연 생성
AiCharge newCharge = new AiCharge(
today.getYear(),
today.getMonthValue(),
BigDecimal.ZERO
);
try {
return chargeRepository.save(newCharge);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// 동시 생성된 경우, 다시 조회
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
}
}
}
🤖 Prompt for AI Agents
In gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java
around lines 27-30, replace the current getMonthlyCharge() with a
getOrCreateMonthlyCharge() that guarantees returning an AiCharge for the current
Seoul year/month by first attempting to fetch via
chargeRepository.getByYearAndMonth(...) and, if absent, creating and saving a
new AiCharge (populate year, month and any required defaults) in a safe/durable
way (wrap in a transaction and handle concurrent creation — e.g., attempt insert
and on unique constraint failure re-query or use repository.save with proper
locking), then update usages (PrAnalysisFacadeService) to call
getOrCreateMonthlyCharge(); ensure exceptions are handled and the
repository/save path is tested.


public void addCharge(double consumedCharge) {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
chargeRepository.addCharge(today.getYear(), today.getMonthValue(), consumedCharge);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.devoops.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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.pranalysis.PrAnalysisFacadeService;
import com.devoops.service.pullrequest.PullRequestService;
import com.devoops.service.question.QuestionService;
import java.util.List;
Expand All @@ -22,7 +22,7 @@ public class QuestionEventListener {

private final PullRequestService pullRequestService;
private final QuestionService questionService;
private final PrAnalysisService prAnalysisService;
private final PrAnalysisFacadeService prAnalysisFacadeService;

@Async
@EventListener(QuestionCreateEvent.class)
Expand All @@ -31,7 +31,7 @@ public void createQuestion(QuestionCreateEvent questionCreateEvent) {
GithubToken githubToken = questionCreateEvent.getToken();
PullRequest readyPullRequest = questionCreateEvent.getInitializedPullRequest();

AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisService.analyzePullRequest(request, githubToken);
AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisFacadeService.analyzePullRequest(request, githubToken);

PullRequest updatedPullRequest = pullRequestService.updateAnalyzeResult(
readyPullRequest.getId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.devoops.service.pranalysis;

import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
class AiChargeScheduler {

private final AiChargeService aiChargeService;

@Scheduled(cron = "0 55 23 L * ?", zone = "Asia/Seoul")
public void createNextMonthCharge() {
aiChargeService.initializeNextMonth();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,20 @@
import com.devoops.domain.repository.analysis.AiChargeRepository;
import com.devoops.dto.AppWebhookEventRequest;
import com.devoops.dto.request.AdaptedAnalyzePrResponse;
import java.time.LocalDate;
import java.time.ZoneId;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PrAnalysisService {
public class PrAnalysisFacadeService {

private final GithubAdaptor githubAdaptor;
private final PrAnalysisAdapter prAnalysisAdapter;
private final AiChargeRepository chargeRepository;
private final AiChargeService aiChargeService;

public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) {
String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken());
ZoneId seoulZoneId = ZoneId.of("Asia/Seoul");
LocalDate today = LocalDate.now(seoulZoneId);
AiCharge aiCharge = chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
AiCharge aiCharge = aiChargeService.getMonthlyCharge();
OpenAiModel aiModel = OpenAiModel.getModelByUsage(aiCharge.getCharge());

AdaptedAnalyzePrResponse result = prAnalysisAdapter.analyze(
Expand All @@ -36,7 +32,7 @@ public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest reques
);

double consumedCharge = aiModel.getCharge(result.promptTokens(), result.completionTokens());
chargeRepository.addCharge(today.getYear(), today.getMonthValue(), consumedCharge);
aiChargeService.addCharge(consumedCharge);
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.devoops.service.pranalysis;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

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

class AiChargeSchedulerTest extends BaseMcpTest {

@Autowired
private AiChargeScheduler aiChargeScheduler;

@Autowired
private AiChargeRepository aiChargeRepository;

@Nested
class CreateNextMonthCharge {

@Test
void 다음달_비용을_초기화한다() {
ZoneId zoneId = ZoneId.of("Asia/Seoul");
YearMonth yearMonth = YearMonth.from(LocalDate.now(zoneId));
YearMonth nextMonth = yearMonth.plusMonths(1);

aiChargeScheduler.createNextMonthCharge();
Comment on lines +29 to +33
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

경계 시각(자정/말일) 플래키 발생 가능 — Clock 주입으로 결정론 보장 필요

테스트/서비스가 각각 LocalDate.now(Asia/Seoul)을 호출하므로 자정 전후(특히 말일 23:59~00:01)에 월 계산이 어긋날 수 있습니다. AiChargeServiceClock을 주입해 테스트에서는 고정 Clock을 주입하도록 리팩터링해 주세요.

원하시면 AiChargeServiceClock 도입 및 테스트용 고정 Clock 설정까지 패치 드리겠습니다.


🏁 Script executed:

#!/bin/bash
set -e

# Locate the test file
TEST_FILE=$(find . -type f -name AiChargeSchedulerTest.java)
echo "=== Test file: $TEST_FILE ==="
sed -n '1,200p' "$TEST_FILE"

# Locate the service file defining AiChargeService
SERVICE_FILE=$(grep -R --include="*.java" "class AiChargeService" -l .)
echo "=== Service file(s): $SERVICE_FILE ==="
# Show the createNextMonthCharge method context
grep -R --include="*.java" -n "createNextMonthCharge" -C3 $SERVICE_FILE
sed -n '1,200p' $SERVICE_FILE | sed -n '/createNextMonthCharge/,/^}/p'

# Check for LocalDate.now usage in the service
echo "=== LocalDate.now usages in service ==="
grep -R --include="*.java" "LocalDate.now" -n $SERVICE_FILE

# Check if Clock is injected anywhere in the service or test
echo "=== Clock references in codebase ==="
grep -R --include="*.java" "\bClock\b" -n .

Length of output: 2565


🏁 Script executed:

#!/bin/bash
set -e

# AiChargeScheduler 클래스 파일 위치 확인
SCHEDULER_FILE=$(find . -type f -name AiChargeScheduler.java)
echo "=== Scheduler file: $SCHEDULER_FILE ==="

# createNextMonthCharge 메서드 주변 코드 확인
grep -n -C3 "createNextMonthCharge" "$SCHEDULER_FILE"

# LocalDate.now 사용 여부 확인
grep -n "LocalDate.now" "$SCHEDULER_FILE"

Length of output: 808


🏁 Script executed:

#!/bin/bash
set -e

# Locate AiChargeService file
SERVICE_FILE=$(find . -type f -path "*com/devoops/service/pranalysis/AiChargeService.java")
echo "=== Service file: $SERVICE_FILE ==="

# Show top of file
sed -n '1,200p' "$SERVICE_FILE"

# Show initializeNextMonth method context
grep -n -C3 "initializeNextMonth" "$SERVICE_FILE"

# Check for LocalDate.now usage in service
grep -n "LocalDate.now" "$SERVICE_FILE"

Length of output: 2505


AiChargeService에 Clock 주입하고 테스트에서 고정 Clock 사용
서비스의 LocalDate.now(SEOUL_ZONE_ID) 호출을 생성자 주입된 Clock으로 교체하고, 테스트에서는 고정된 Clock 인스턴스를 주입해 경계 시각(자정/말일) 플래키를 방지하세요.

🤖 Prompt for AI Agents
In
gss-mcp-app/src/test/java/com/devoops/service/pranalysis/AiChargeSchedulerTest.java
around lines 29 to 33, replace direct calls to LocalDate.now(SEOUL_ZONE_ID) in
the AiChargeService with a constructor-injected Clock and update the test to
pass a fixed Clock for Asia/Seoul so boundary timing is deterministic: modify
the service to accept a Clock (use it with LocalDate.now(clock) and
ZoneId.of("Asia/Seoul") as needed), add a constructor or setter to inject the
Clock, and in this test create a fixed Clock at a known Instant with
ZoneId.of("Asia/Seoul") then construct/inject the AiChargeService (or
AiChargeScheduler) with that fixed Clock before calling createNextMonthCharge().


AiCharge nextMonthAiCharge = aiChargeRepository.getByYearAndMonth(
nextMonth.getYear(),
nextMonth.getMonthValue()
);
assertAll(
() -> assertThat(nextMonthAiCharge.getYear()).isEqualTo(nextMonth.getYear()),
() -> assertThat(nextMonthAiCharge.getMonth()).isEqualTo(nextMonth.getMonthValue()),
() -> assertThat(nextMonthAiCharge.getCharge().doubleValue()).isEqualTo(0.0)
);
}
}

}
Loading