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
17 changes: 0 additions & 17 deletions gss-api-app/src/main/java/com/devoops/event/AnalyzePrEvent.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devoops.event;

import com.devoops.domain.entity.github.answer.Answer;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;

@Getter
public class UpdateAnswerEvent extends ApplicationEvent {

private final Answer answer;
private final long userId;

public UpdateAnswerEvent(Object source, Answer answer, long userId) {
super(source);
this.answer = answer;
this.userId = userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.devoops.event.listener;

import com.devoops.domain.entity.github.answer.Answer;
import com.devoops.event.UpdateAnswerEvent;
import com.devoops.service.answerranking.AnswerRankingService;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class AnswerEventListener {

private final AnswerRankingService answerRankingService;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateAnswer(UpdateAnswerEvent updateAnswerEvent) {
Answer answer = updateAnswerEvent.getAnswer();
long userId = updateAnswerEvent.getUserId();
answerRankingService.push(answer, userId);
}
Comment on lines +18 to +24
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

리스너에 트랜잭션 경계를 추가하세요 (특히 @async 환경).

커밋 후 다른 스레드에서 실행되므로, JPA/MyBatis를 사용한다면 쓰기 작업에 트랜잭션이 없어 실패(예: TransactionRequiredException)할 수 있습니다. 리스너 메서드에 REQUIRES_NEW 트랜잭션을 부여해 랭킹 저장을 독립적으로 보장하세요.

 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
@@
     @Async
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
     public void updateAnswer(UpdateAnswerEvent updateAnswerEvent) {
         Answer answer = updateAnswerEvent.getAnswer();
         long userId = updateAnswerEvent.getUserId();
         answerRankingService.push(answer, userId);
     }

대안: AnswerRankingService.push(...) 자체를 @Transactional로 감싸도 됩니다(서비스 계층 표준화).

📝 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
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateAnswer(UpdateAnswerEvent updateAnswerEvent) {
Answer answer = updateAnswerEvent.getAnswer();
long userId = updateAnswerEvent.getUserId();
answerRankingService.push(answer, userId);
}
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class AnswerEventListener {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateAnswer(UpdateAnswerEvent updateAnswerEvent) {
Answer answer = updateAnswerEvent.getAnswer();
long userId = updateAnswerEvent.getUserId();
answerRankingService.push(answer, userId);
}
}
🤖 Prompt for AI Agents
In gss-api-app/src/main/java/com/devoops/event/listener/AnswerEventListener.java
around lines 18 to 24, the @Async listener runs after commit on another thread
and currently has no transactional boundary, which can cause
TransactionRequiredException when performing writes; add a new transaction
boundary (e.g., annotate the listener method with a propagation of REQUIRES_NEW)
so the ranking save runs in its own transaction, or alternatively make
AnswerRankingService.push(...) @Transactional to ensure the write executes
within a transaction.

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


import com.devoops.dto.AppWebhookEventRequest;
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 jakarta.annotation.PostConstruct;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
Expand All @@ -19,15 +14,9 @@ public class PrAnalysisPublisher {

private final RedisTemplate<String, Object> redisTemplate;
private final ChannelTopic channelTopic;
private final ObjectMapper objectMapper;

public void publish(List<AppWebhookEventRequest> eventList) {
// try {
// String message = objectMapper.writeValueAsString(eventList);
redisTemplate.convertAndSend(channelTopic.getTopic(), eventList);
// } catch (JsonProcessingException e) {
// throw new GssException(ErrorCode.REDIS_PUBLISH_ERROR);
// }
redisTemplate.convertAndSend(channelTopic.getTopic(), eventList);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
import com.devoops.domain.entity.github.pr.RecordStatus;
import com.devoops.domain.entity.user.User;
import com.devoops.dto.request.AnswerPutRequests;
import com.devoops.event.UpdateAnswerEvent;
import com.devoops.service.answer.AnswerService;
import com.devoops.service.answerranking.AnswerRankingService;
import com.devoops.service.pullrequest.PullRequestService;
import com.devoops.service.question.QuestionService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
Expand All @@ -22,7 +25,7 @@ public class QuestionFacadeService {
private final PullRequestService pullRequestService;
private final QuestionService questionService;
private final AnswerService answerService;
private final AnswerRankingService answerRankingService;
private final ApplicationEventPublisher eventPublisher;

public Answer initializeAnswer(long questionId, User user) {
PullRequest pullRequest = pullRequestService.findByQuestionId(questionId);
Expand All @@ -32,12 +35,14 @@ public Answer initializeAnswer(long questionId, User user) {
return questionService.initializeAnswer(questionId, user);
}

@Transactional
public Answer updateAnswer(long answerId, String updateContent, long userId) {
Answer answer = questionService.updateAnswer(answerId, updateContent);
answerRankingService.push(answer, userId);
eventPublisher.publishEvent(new UpdateAnswerEvent(this, answer, userId));
return answer;
}

@Transactional
public Answers updateAllAnswers(AnswerPutRequests updateRequests) {
List<AnswerUpdateCommand> updateCommands = updateRequests.answers()
.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.springframework.beans.factory.annotation.Autowired;

class AnswerRankingServiceTest extends BaseServiceTest {
Expand Down Expand Up @@ -47,6 +49,21 @@ class Push {
assertThat(userRanking.getRankings()).hasSize(1);
}

@Test
void 답변내용이_비어있으면_랭킹에_반영하지_않는다() {
User user = userGenerator.generate("김건우");
GithubRepository repo = repoGenerator.generate(user, "건우의 레포");
PullRequest pullRequest = pullRequestGenerator.generate("최초 PR", RecordStatus.PENDING, ProcessingStatus.DONE,
repo, LocalDateTime.now());
Question question1 = questionGenerator.generate(pullRequest, "질문1");
Answer answer = answerGenerator.generate(question1, "");

answerRankingService.push(answer, user.getId());

AnswerRankings userRanking = answerRankingDomainRepository.findAllByUserId(user.getId());
assertThat(userRanking.getRankings()).isEmpty();
}

@Test
void 유저의_PR_랭킹을_모두_가져온다() {
User user = userGenerator.generate("김건우");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
import com.devoops.domain.repository.github.pr.PullRequestDomainRepository;
import com.devoops.dto.request.AnswerPutRequest;
import com.devoops.dto.request.AnswerPutRequests;
import com.devoops.event.UpdateAnswerEvent;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;

class QuestionFacadeServiceTest extends BaseServiceTest {

Expand Down Expand Up @@ -113,4 +117,33 @@ class UpdateAllAnswers {
);
}
}

@Nested
@RecordApplicationEvents
class UpdateAnswers {

@Autowired
private ApplicationEvents applicationEvents;

@BeforeEach
void setUp() {
applicationEvents.clear();
}

@Test
void 회고를_업데이트_시_랭킹_갱신_이벤트를_발행한다() {
User user = userGenerator.generate("김건우");
GithubRepository repo = repoGenerator.generate(user, "건우의 레포");
PullRequest pr1 = pullRequestGenerator.generate("PR1", RecordStatus.PENDING, ProcessingStatus.DONE, repo, LocalDateTime.now());
Question question1 = questionGenerator.generate(pr1, "질문1");
Answer answer1 = answerGenerator.generate(question1, "answer1");

questionFacadeService.updateAnswer(answer1.getId(), "updateContent", user.getId());

assertThat(applicationEvents.stream(UpdateAnswerEvent.class))
.hasSize(1)
.allMatch(event -> event.getAnswer().getId().equals(answer1.getId()))
.allMatch(event -> event.getUserId() == user.getId());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dev-oops:
- "summaryDetail"은 변경 내용을 항목별로 요약한 제목(title) + 설명(description) 쌍으로 구성해.
- "category"는 기술적인 관점에서 PR 코드 변경 내용을 반영하여 선택해 (예: 성능, 보안, 확장성, 유지보수성, 테스트 등)
- "question"은 각 category에 대해 기술 면접에서 사용할 수 있는 질문이어야 해.
- "question"은 가능한 255자가 넘지 않도록 짧고 명료하게 질문해.
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

프롬프트 지시만으로는 255자 제한을 보장하지 못합니다 — 스키마/후처리로 강제하세요

LLM 프롬프트에만 의존하면 간헐적으로 255자를 초과할 수 있어 동일 계열의 제약 위반이 재발할 수 있습니다. JSON 스키마에 maxLength=255를 명시하고(format-message가 주입하는 스키마에 반영), 파서 단계에서 초과 문항은 안전하게 잘라내는 후처리를 추가하는 것을 권장합니다. 라인 문구도 “가능한”이 아닌 “반드시/초과 시 절단”으로 바꿔 의도를 강화해 주세요.

Apply this diff to tighten the instruction:

-      - "question"은 가능한 255자가 넘지 않도록 짧고 명료하게 질문해.
+      - "question"은 반드시 255자 이내로 작성하고, 초과 시 앞 252자만 남기고 '...'로 절단해.

다음 스크립트로 레포 내 강제 수단이 있는지 확인해 주세요(스키마 maxLength, DTO @SiZe, DB 컬럼 등):

필요하시면: (1) 스키마에 maxLength 추가, (2) 파서 후처리 유틸(초과 시 안전 절단), (3) 경계 테스트(254/255/256자) 작성까지 패치 드리겠습니다.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== JSON 스키마에 maxLength 존재 여부 체크 =="
rg -n -C2 -i --type=json --type=yaml 'question"|questions"|prompt.*question|maxLength' || true

echo "== DTO/검증 애노테이션(@Size<=255) 존재 여부 체크 =="
rg -n -C2 -i --type=java --type=kt '@Size\s*\(\s*max\s*=\s*255' || true
rg -n -C2 -i --type=java --type=kt '\bString\s+question\b|\bvar\s+question\b|\bval\s+question\b' || true

echo "== DB 제약(컬럼 길이) 존재 여부 체크 =="
rg -n -C2 -i --type=sql 'question.*varchar\s*\(\s*255\s*\)|varchar\s*\(\s*255\s*\).*question' || true

Length of output: 2225


프롬프트 문구 및 제약 강제 적용

  • gss-client/gss-mcp-client/src/main/resources/application-mcp-client.yml:11을
    -      - "question"은 가능한 255자가 넘지 않도록 짧고 명료하게 질문해.
    +      - "question"은 반드시 255자 이내로 작성하고, 초과 시 앞 252자만 남기고 '...'로 절단해.
  • 프로젝트 전반 JSON 스키마(application-mcp-client.yml 등)에 question 필드에 maxLength: 255 명시
  • 응답 파서 단계에 255자 초과 시 앞 252자만 남기고 ‘...’로 절단하는 후처리 로직 추가
📝 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
- "question"가능한 255자가 넘지 않도록 짧고 명료하게 질문해.
- "question"반드시 255자 이내로 작성하고, 초과 시 앞 252자만 남기고 '...'로 절단해.
🤖 Prompt for AI Agents
gss-client/gss-mcp-client/src/main/resources/application-mcp-client.yml around
line 11: the project requires enforcing the question length constraint and
truncation behavior; add "maxLength: 255" to the question field in the JSON
schema(s) (application-mcp-client.yml and any other schema files used across the
project) so validators reject >255 characters, and implement a response-parser
post-processing step that, when the parsed question exceeds 255 characters,
truncates it to the first 252 characters and appends "..." before
returning/storing the value.

- 각 질문들은 반드시 PR 코드 변경 내용("diff")을 인용해서 생성해.
- "diff"를 굉장히 자세하게 분석하고 몇몇 질문에는 코드를 반영해서 만들어줘
- 질문 수는 카테고리마다 3개 이상 만들어.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.devoops.domain.entity.github.answer;

import io.micrometer.common.util.StringUtils;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

Expand All @@ -16,4 +17,8 @@ public class Answer {
public static Answer initialize(long questionId) {
return new Answer(null, questionId, INITIALIZED_ANSWER_CONTENT);
}

public boolean isBlank() {
return StringUtils.isBlank(content);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,4 @@ public interface AnswerRankingDomainRepository {
AnswerRanking update(long pullRequestId, long questionId);

void deleteById(long id);

void deleteAllInPullRequests(PullRequests pullRequests);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class AnswerRankingEntity {
private long questionId;

@NotBlank
@Column(name = "content")
@Column(name = "content", columnDefinition = "TEXT")
private String questionContent;
Comment on lines +29 to 30
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

DB 컬럼을 TEXT로 고정하면 벤더 종속이 증가합니다. @lob로 매핑하고 마이그레이션을 동반하세요.

  • JPA 이식성: @Lob(+ @Column(name="content")) 조합이 벤더 중립적입니다.
  • 운영 필수: 엔티티 변경만으로는 프로덕션 스키마가 바뀌지 않습니다. Flyway/Liquibase 마이그레이션이 반드시 필요합니다.

선호 diff:

-    @NotBlank
-    @Column(name = "content", columnDefinition = "TEXT")
+    @NotBlank
+    @Lob
+    @Column(name = "content")
     private String questionContent;

필요 import:

import jakarta.persistence.Lob;

마이그레이션 존재 여부 확인 스크립트:

주의:

  • 이슈([REFACTOR] ANSWER_RANKING_ENTITY 로직 분리 #90) 원인은 “사용자 답변(content)” 255 제한입니다. 현재 변경은 Ranking Entity의 content입니다. 실제 사용자 답변 테이블(예: AnswerEntity)의 컬럼 타입도 TEXT/CLOB로 변경되었는지 반드시 확인해주세요. 필요시 마이그레이션 초안 드리겠습니다.

🏁 Script executed:

#!/bin/bash
# 1) Answer(사용자 답변) 및 Ranking의 content 컬럼 타입 변경 반영 여부 점검

# AnswerEntity 매핑에서 content 필드와 컬럼 정의 확인
rg -nP -C2 '(class\s+AnswerEntity\b)|(@Column\([^)]*name\s*=\s*"content"[^)]*\))' --g 'gss-**/*.java'

# "TEXT/CLOB" 정의가 어디에 쓰였는지 검색
rg -nP 'columnDefinition\s*=\s*"TEXT"|@Lob' --g 'gss-**/*'

# 마이그레이션 SQL에 content 타입 변경이 있는지 검색
rg -nP -C2 'ALTER\s+TABLE.+content.+(TEXT|CLOB)|MODIFY\s+COLUMN\s+`?content`?.+(TEXT|LONGTEXT)|TYPE\s+TO\s+(TEXT|CLOB)' --g '**/*.sql'

Length of output: 1519


🏁 Script executed:

#!/bin/bash
# 1) AnswerEntity 소스 파일 경로 찾기
rg -lP 'class\s+AnswerEntity' --glob '*.java'

# 2) AnswerEntity 및 AnswerRankingEntity에서 content 컬럼 어노테이션 확인
rg -n '@Column\([^)]*name\s*=\s*"content"[^)]*\)' --glob '*.java'

# 3) @Lob 어노테이션 사용 여부 확인
rg -n '@Lob' --glob '*.java'

# 4) SQL 마이그레이션에서 content 컬럼 타입 변경 반영 여부 확인
rg -nP -C2 'ALTER\s+TABLE.+content.+(TEXT|CLOB)|MODIFY\s+COLUMN.+content.+(TEXT|LONGTEXT)|TYPE\s+TO\s+(TEXT|CLOB)' --glob '*.sql'

Length of output: 652


🏁 Script executed:

#!/bin/bash
# 1) AnswerEntity content 필드 매핑 확인
rg -nP -C5 'private\s+String\s+content' gss-domain/src/main/java/com/devoops/jpa/entity/github/answer/AnswerEntity.java

# 2) 프로젝트 내 SQL 파일 목록
find gss-domain -type f -name "*.sql"

# 3) SQL 마이그레이션에서 content 컬럼 변경 반영 검색
rg -nP 'content' --glob 'gss-domain/**/*.sql' -C2

Length of output: 578


🏁 Script executed:

#!/bin/bash
# 전체 리포지토리에서 SQL 마이그레이션 파일 확인
find . -type f -name '*.sql'

# SQL 파일에서 content 컬럼 변경 반영 여부 검색
rg -nP 'content' --glob '*.sql' -C2

Length of output: 215


@lob 매핑 적용 및 마이그레이션 스크립트 추가 필요

  • gss-domain/src/main/java/com/devoops/jpa/entity/github/answer/AnswerRankingEntity.java (29–31):
    -    @Column(name = "content", columnDefinition = "TEXT")
    +    @Lob
    +    @Column(name = "content")
  • gss-domain/src/main/java/com/devoops/jpa/entity/github/answer/AnswerEntity.java (33–34):
    -    @Column(columnDefinition = "TEXT")
    +    @Lob
    +    @Column(name = "content")
  • Flyway/Liquibase 마이그레이션 SQL 파일이 확인되지 않습니다. 기존 content 컬럼의 VARCHAR(255) → TEXT/CLOB 변경 스크립트를 반드시 추가하세요.
  • 필요한 import:
    import jakarta.persistence.Lob;


@Column(name = "pull_request_id")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,6 @@ public void deleteById(long id) {
answerRankingJpaRepository.deleteById(id);
}

@Override
@Transactional
public void deleteAllInPullRequests(PullRequests pullRequests) {
List<Long> pullRequestIds = pullRequests.getValues()
.stream()
.map(PullRequest::getId)
.toList();
answerRankingJpaRepository.deleteByPullRequestIdIn(pullRequestIds);
}

private QuestionEntity findQuestionById(long questionId) {
return questionJpaRepository.findById(questionId)
.orElseThrow(() -> new GssException(ErrorCode.QUESTION_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public AnswerRankings findUserRanking(long userId) {
}

public void push(Answer answer, long userId) {
if(answer.isBlank()) {
return;
}
AnswerRankings answerRankings = findUserRanking(userId);
Question question = questionDomainRepository.findById(answer.getQuestionId());

Expand Down
Loading