diff --git a/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java b/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java index 986e319..6b421f5 100644 --- a/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java +++ b/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java @@ -15,6 +15,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -99,6 +100,25 @@ public ApiResponse connectCommi return ApiResponse.onSuccess(applicationCommandService.connectCommit(applicationId, request)); } + @DeleteMapping("/{applicationId}/commits") + @Operation(summary = "커밋 연결 해제 API", description = """ + 적용사항에 연결된 커밋을 해제하는 API입니다. + + 해제할 커밋 ID 하나를 `commit_id`로 전달합니다. + 예시: `{ "commit_id": 1 }` + 요청한 커밋이 연결되어 있지 않으면 요청이 실패합니다. + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = DecisionErrorCode.class, name = "APPLICATION_NOT_FOUND"), + @ApiErrorCodeExample(value = DecisionErrorCode.class, name = "APPLICATION_COMMIT_NOT_CONNECTED") + }) + public ApiResponse disconnectCommit( + @PathVariable Long applicationId, + @Valid @RequestBody DecisionRequest.CommitDisconnectionDTO request) { + return ApiResponse.onSuccess(applicationCommandService.disconnectCommit(applicationId, request)); + } + @PostMapping("/{decisionId}/recommendations") @Operation(summary = "추천 결과 저장 API", description = "적용사항의 추천 결과를 저장하는 API입니다.") @ApiErrorCodeExamples({ diff --git a/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java b/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java index 7b1dbc9..3010d3f 100644 --- a/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java +++ b/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java @@ -20,6 +20,18 @@ public static class CommitConnectionDTO { private List<@NotNull Long> commitIds; } + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "커밋 연결 해제 요청") + public static class CommitDisconnectionDTO { + + @Schema(description = "해제할 커밋 ID", example = "1") + @NotNull + private Long commitId; + } + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor diff --git a/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java b/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java index 1709e84..56d0589 100644 --- a/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java +++ b/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java @@ -13,6 +13,7 @@ public enum DecisionErrorCode implements BaseErrorCode { DECISION_NOT_FOUND(HttpStatus.NOT_FOUND, "DECISION_404", "존재하지 않는 결정사항입니다."), APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "APPLICATION_404", "존재하지 않는 적용사항입니다."), APPLICATION_COMMIT_ALREADY_CONNECTED(HttpStatus.CONFLICT, "APPLICATION_COMMIT_409", "이미 연결된 커밋입니다."), + APPLICATION_COMMIT_NOT_CONNECTED(HttpStatus.NOT_FOUND, "APPLICATION_COMMIT_404", "연결되지 않은 커밋입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java index 2fab734..07f047e 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java @@ -4,6 +4,7 @@ import com.whylog.server.domain.decision.entity.ApplicationCommitsId; import java.util.List; 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; @@ -19,4 +20,11 @@ public interface ApplicationCommitsRepository extends JpaRepository findByApplicationId(@Param("applicationId") Long applicationId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM ApplicationCommits ac + WHERE ac.decisionCommits.id IN :decisionCommitIds + """) + void deleteByDecisionCommitsIdIn(@Param("decisionCommitIds") List decisionCommitIds); } diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java index 8a09443..a2dd373 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java @@ -1,11 +1,29 @@ package com.whylog.server.domain.decision.repository; import com.whylog.server.domain.decision.entity.DecisionCommits; +import java.util.List; 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 DecisionCommitsRepository extends JpaRepository { // 추천 결과 저장시 중복 방지용 Optional findByDecisionIdAndCommitId(Long decisionId, Long commitId); + + @Query(""" + SELECT dc.id + FROM DecisionCommits dc + WHERE dc.commitId IN :commitIds + """) + List findIdsByCommitIdIn(@Param("commitIds") List commitIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM DecisionCommits dc + WHERE dc.commitId IN :commitIds + """) + void deleteByCommitIdIn(@Param("commitIds") List commitIds); } diff --git a/src/main/java/com/whylog/server/domain/decision/service/ApplicationCommandService.java b/src/main/java/com/whylog/server/domain/decision/service/ApplicationCommandService.java index a8efa61..8a5a285 100644 --- a/src/main/java/com/whylog/server/domain/decision/service/ApplicationCommandService.java +++ b/src/main/java/com/whylog/server/domain/decision/service/ApplicationCommandService.java @@ -50,12 +50,13 @@ public ApplicationResponse.CommitConnectionResponseDTO connectCommit(Long applic } // 요청한 커밋 중 하나라도 이미 연결되어 있으면 전체 요청을 실패 처리 + if (commitIds.stream().anyMatch(commitConnectionRepository::existsByCommitId)) { + throw new ErrorHandler(DecisionErrorCode.APPLICATION_COMMIT_ALREADY_CONNECTED); + } + List commitConnectionIds = commitIds.stream() .map(commitId -> new CommitConnectionId(applicationId, commitId)) .toList(); - if (commitConnectionIds.stream().anyMatch(commitConnectionRepository::existsById)) { - throw new ErrorHandler(DecisionErrorCode.APPLICATION_COMMIT_ALREADY_CONNECTED); - } // 신규 커밋 연결 정보를 저장 List commitConnections = commitConnectionIds.stream() @@ -69,4 +70,25 @@ public ApplicationResponse.CommitConnectionResponseDTO connectCommit(Long applic .commitIds(commitIds) .build(); } + + // 적용사항에 연결된 커밋 하나를 해제합니다. + @Transactional + public ApplicationResponse.CommitConnectionResponseDTO disconnectCommit(Long applicationId, + DecisionRequest.CommitDisconnectionDTO request) { + applicationRepository.findById(applicationId) + .orElseThrow(ApplicationNotFoundException::new); + + Long commitId = request.getCommitId(); + CommitConnectionId commitConnectionId = new CommitConnectionId(applicationId, commitId); + if (!commitConnectionRepository.existsById(commitConnectionId)) { + throw new ErrorHandler(DecisionErrorCode.APPLICATION_COMMIT_NOT_CONNECTED); + } + + commitConnectionRepository.deleteById(commitConnectionId); + + return ApplicationResponse.CommitConnectionResponseDTO.builder() + .applicationId(applicationId) + .commitIds(List.of(commitId)) + .build(); + } } diff --git a/src/main/java/com/whylog/server/domain/git/controller/GitController.java b/src/main/java/com/whylog/server/domain/git/controller/GitController.java index 4ce8747..4c38c32 100644 --- a/src/main/java/com/whylog/server/domain/git/controller/GitController.java +++ b/src/main/java/com/whylog/server/domain/git/controller/GitController.java @@ -2,7 +2,6 @@ import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.domain.git.dto.GitResponse; -import com.whylog.server.domain.git.entity.Commit; import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.domain.git.service.GitCommandService; import com.whylog.server.domain.git.service.GitQueryService; @@ -17,7 +16,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -42,6 +40,7 @@ public class GitController { """) @ApiErrorCodeExamples({ @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = GitErrorCode.class, name = "GITHUB_TOKEN_INVALID"), @ApiErrorCodeExample(value = ErrorStatus.class, name = "_INTERNAL_SERVER_ERROR") }) public ApiResponse registerGitHubToken( @@ -50,6 +49,30 @@ public ApiResponse registerGitHubToken( return ApiResponse.onSuccess(gitCommandService.registerGitHubToken(memberId, request.getAccessToken())); } + @DeleteMapping("/github/token") + @Operation( + summary = "GitHub Access Token 삭제 API", + description = "현재 로그인한 사용자의 GitHub Access Token을 삭제합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + }) + public ApiResponse deleteGitHubToken( + @Parameter(hidden = true) @CurrentMember Long memberId) { + return ApiResponse.onSuccess(gitCommandService.deleteGitHubToken(memberId)); + } + + @GetMapping("/github/token/status") + @Operation( + summary = "GitHub Access Token 등록 여부 조회 API", + description = "현재 로그인한 사용자의 GitHub Access Token 등록 여부를 true/false로 조회합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + }) + public ApiResponse getGitHubTokenStatus( + @Parameter(hidden = true) @CurrentMember Long memberId) { + return ApiResponse.onSuccess(gitQueryService.getGitHubTokenStatus(memberId)); + } + @GetMapping("/teams/{teamId}/repositories") @Operation( summary = "팀의 연동된 레포지토리 목록 조회", @@ -109,12 +132,32 @@ public ApiResponse syncRepository( return ApiResponse.onSuccess(GitResponse.RepositorySyncResponseDTO.from(repositoryId)); } + @DeleteMapping("/repositories/{repositoryId}") + @Operation( + summary = "GitHub 레포지토리 삭제", + description = """ + 등록된 레포지토리를 삭제합니다. + + 레포지토리를 삭제하면 관련된 커밋, 커밋 분석, 연결/추천 커밋 데이터도 함께 삭제됩니다. + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = GitErrorCode.class, name = "REPOSITORY_NOT_FOUND") + }) + public ApiResponse deleteRepository( + @PathVariable Long repositoryId) { + return ApiResponse.onSuccess(gitCommandService.deleteRepository(repositoryId)); + } + @GetMapping("/repositories/{repositoryId}/commits") @Operation( summary = "커밋 목록 조회 (커서 기반 무한스크롤)", description = """ 10개씩 커밋을 조회합니다. + 각 커밋에는 기본 정보와 함께 연결된 적용사항 정보가 포함됩니다. + 커밋이 적용사항에 연결되어 있지 않다면 `connectedApplication`은 null로 반환됩니다. + 📌 사용 방법: 1. 첫 요청: cursor 파라미터 없음 2. 응답의 hasNext가 true면, nextCursorId를 cursor로 다시 요청 @@ -124,6 +167,7 @@ public ApiResponse syncRepository( - hasNext: 다음 페이지 존재 여부 (더 불러올 커밋이 있으면 true) - nextCursorId: 다음 요청에 사용할 커서 ID - isFirst: 첫 페이지 여부 + - connectedApplication: 커밋에 연결된 적용사항 정보 (없으면 null) """) @ApiErrorCodeExamples({ @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), @@ -133,13 +177,7 @@ public ApiResponse getCommitsCursor( @PathVariable Long repositoryId, @Parameter(description = "이전 조회의 마지막 커밋 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursor) { - - Slice commitSlice = gitQueryService.getCommitsByRepository( - repositoryId, - cursor - ); - - return ApiResponse.onSuccess(GitResponse.CommitListResponseDTO.from(commitSlice, cursor)); + return ApiResponse.onSuccess(gitQueryService.getCommitListResponse(repositoryId, cursor)); } @GetMapping("/repositories/{repositoryId}/commits/{commitHash}") @@ -172,6 +210,3 @@ public ApiResponse getCommitDetail( return ApiResponse.onSuccess(commitDetail); } } - - - diff --git a/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java b/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java index ac34fd0..51ba4c6 100644 --- a/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java +++ b/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.git.dto; +import com.whylog.server.domain.decision.entity.Application; import com.whylog.server.domain.git.entity.Commit; import com.whylog.server.domain.git.entity.Repository; import io.swagger.v3.oas.annotations.media.Schema; @@ -92,7 +93,10 @@ public static class CommitDTO { @Schema(description = "삭제된 줄 수", example = "12") private Integer deletedLines; - public static CommitDTO from(Commit commit) { + @Schema(description = "연결된 적용사항", nullable = true) + private ConnectedApplicationDTO connectedApplication; + + public static CommitDTO from(Commit commit, ConnectedApplicationDTO connectedApplication) { return CommitDTO.builder() .commitId(commit.getId()) .hash(commit.getHash()) @@ -101,8 +105,30 @@ public static CommitDTO from(Commit commit) { .dateTime(commit.getDateTime()) .addedLines(commit.getAddedLines()) .deletedLines(commit.getDeletedLines()) + .connectedApplication(connectedApplication) .build(); } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "연결된 적용사항 정보") + public static class ConnectedApplicationDTO { + + @Schema(description = "적용사항 ID", example = "1") + private Long applicationId; + + @Schema(description = "적용사항 이름", example = "Redis 기술 변경") + private String name; + + public static ConnectedApplicationDTO from(Application application) { + return ConnectedApplicationDTO.builder() + .applicationId(application.getId()) + .name(application.getName()) + .build(); + } + } } @Getter @@ -142,7 +168,7 @@ public static class CommitDetailDTO { @Schema(description = "변경된 파일 목록") private List changedFileList; - public static CommitDetailDTO of(Commit commit, List changedFileList) { + public static CommitDetailDTO of(Commit commit, String description, List changedFileList) { return CommitDetailDTO.builder() .commitId(commit.getId()) .hash(commit.getHash()) @@ -151,7 +177,7 @@ public static CommitDetailDTO of(Commit commit, List changedFile .authorEmail(commit.getAuthorEmail()) .authorProfileImage(commit.getAuthorProfileImage()) .dateTime(commit.getDateTime()) - .description(commit.getMessage()) //TODO: AI 요약값으로 변경 + .description(description) .changedFileCount(changedFileList.size()) .changedFileList(changedFileList) .build(); @@ -195,6 +221,20 @@ public static RepositorySyncResponseDTO from(Long repositoryId) { } } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "레포 삭제 응답") + public static class RepositoryDeleteResponseDTO { + + @Schema(description = "레포 ID", example = "1") + private Long repositoryId; + + @Schema(description = "삭제 성공 여부", example = "true") + private Boolean isRemoved; + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -212,6 +252,40 @@ public static GitHubTokenResponseDTO from(String accessToken) { } } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "깃허브 Access Token 삭제 응답") + public static class GitHubTokenDeleteResponseDTO { + + @Schema(description = "삭제 성공 여부", example = "true") + private Boolean isRemoved; + + public static GitHubTokenDeleteResponseDTO from(Boolean isRemoved) { + return GitHubTokenDeleteResponseDTO.builder() + .isRemoved(isRemoved) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "깃허브 Access Token 등록 여부 응답") + public static class GitHubTokenStatusResponseDTO { + + @Schema(description = "GitHub Access Token 등록 여부", example = "true") + private Boolean isRegistered; + + public static GitHubTokenStatusResponseDTO from(Boolean isRegistered) { + return GitHubTokenStatusResponseDTO.builder() + .isRegistered(isRegistered) + .build(); + } + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -234,9 +308,13 @@ public static class CommitListResponseDTO { @Schema(description = "다음 커서 ID (무한스크롤용)", example = "1") private Long nextCursorId; - public static CommitListResponseDTO from(Slice commitSlice, Long cursorId) { + public static CommitListResponseDTO from( + Slice commitSlice, + Long cursorId, + java.util.Map connectedApplicationsByCommitId + ) { List commitDTOs = commitSlice.getContent().stream() - .map(CommitDTO::from) + .map(commit -> CommitDTO.from(commit, connectedApplicationsByCommitId.get(commit.getId()))) .collect(Collectors.toList()); // 다음 커서 ID 설정 diff --git a/src/main/java/com/whylog/server/domain/git/entity/Commit.java b/src/main/java/com/whylog/server/domain/git/entity/Commit.java index 55448e1..b00c5d7 100644 --- a/src/main/java/com/whylog/server/domain/git/entity/Commit.java +++ b/src/main/java/com/whylog/server/domain/git/entity/Commit.java @@ -8,6 +8,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -48,14 +50,11 @@ public class Commit extends BaseEntity { @Column(name = "deleted_lines", nullable = false) private Integer deletedLines; -// @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List changedFiles = new ArrayList<>(); -// -// @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List commitAnalyses = new ArrayList<>(); -// -// @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List commitConnections = new ArrayList<>(); + @OneToOne(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) + private CommitAnalysis commitAnalysis; + + @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) + private final List commitConnections = new ArrayList<>(); public static Commit create(GitRequest.CommitCreateDTO dto, Repository repository) { Commit commit = new Commit(); diff --git a/src/main/java/com/whylog/server/domain/git/entity/Repository.java b/src/main/java/com/whylog/server/domain/git/entity/Repository.java index eb55d9c..4b9eb10 100644 --- a/src/main/java/com/whylog/server/domain/git/entity/Repository.java +++ b/src/main/java/com/whylog/server/domain/git/entity/Repository.java @@ -44,8 +44,8 @@ public class Repository extends BaseEntity { @JoinColumn(name = "team_id", nullable = false) private Team team; -// @OneToMany(mappedBy = "repository", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List commits = new ArrayList<>(); + @OneToMany(mappedBy = "repository", cascade = CascadeType.ALL, orphanRemoval = true) + private final List commits = new ArrayList<>(); public static Repository create(String name, String url, Team team) { Repository repository = new Repository(); diff --git a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java index 02e1c10..c8594b0 100644 --- a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java +++ b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java @@ -13,6 +13,7 @@ public enum GitErrorCode implements BaseErrorCode { COMMIT_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_2", "존재하지 않는 커밋입니다."), INVALID_GITHUB_URL(HttpStatus.BAD_REQUEST, "GIT_400_1", "유효하지 않은 GitHub URL입니다."), GITHUB_TOKEN_NOT_REGISTERED(HttpStatus.BAD_REQUEST, "GIT_400_2", "GitHub Access Token이 등록되지 않았습니다."), + GITHUB_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "GIT_401_2", "유효하지 않은 GitHub Access Token입니다. 다시 확인해주세요."), TOKEN_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "GIT_500_1", "GitHub 토큰 암호화 중 오류가 발생했습니다."), TOKEN_DECRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "GIT_500_2", "GitHub 토큰 복호화 중 오류가 발생했습니다."), GITHUB_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "GIT_401_1", "GitHub Access Token이 만료되었습니다. 다시 인증해주세요."), diff --git a/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java b/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java index 2b25cba..8764853 100644 --- a/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java @@ -3,6 +3,9 @@ import com.whylog.server.domain.git.entity.CommitAnalysis; 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; import org.springframework.stereotype.Repository; @Repository @@ -10,4 +13,11 @@ public interface CommitAnalysisRepository extends JpaRepository findByCommitId(Long commitId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM CommitAnalysis ca + WHERE ca.commit.repository.id = :repositoryId + """) + void deleteByRepositoryId(@Param("repositoryId") Long repositoryId); } diff --git a/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java b/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java index 6ec1447..eacc344 100644 --- a/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java @@ -4,6 +4,7 @@ import com.whylog.server.domain.git.entity.CommitConnectionId; import java.util.List; 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; import org.springframework.stereotype.Repository; @@ -15,10 +16,29 @@ public interface CommitConnectionRepository extends JpaRepository findByApplicationId(@Param("applicationId") Long applicationId); + + @Query(""" + SELECT cc + FROM CommitConnection cc + JOIN FETCH cc.application a + JOIN FETCH cc.commit c + WHERE c.id IN :commitIds + """) + List findByCommitIds(@Param("commitIds") List commitIds); + + boolean existsByCommitId(Long commitId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM CommitConnection cc + WHERE cc.commit.repository.id = :repositoryId + """) + void deleteByRepositoryId(@Param("repositoryId") Long repositoryId); } diff --git a/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java b/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java index 380227b..46f1de1 100644 --- a/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; 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; import org.springframework.stereotype.Repository; @@ -36,6 +37,20 @@ Set findExistingHashes( """) List findAllWithRepositoryByIdIn(@Param("commitIds") List commitIds); + @Query(""" + SELECT c.id + FROM Commit c + WHERE c.repository.id = :repositoryId + """) + List findIdsByRepositoryId(@Param("repositoryId") Long repositoryId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM Commit c + WHERE c.repository.id = :repositoryId + """) + void deleteByRepositoryId(@Param("repositoryId") Long repositoryId); + // 커서 기반 무한스크롤 - 커밋 목록 조회 @Query("SELECT c FROM Commit c " + "WHERE c.repository.id = :repositoryId " + @@ -52,4 +67,3 @@ Slice findCommitsWithCursor( ); } - diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java index 10a1ad2..392f165 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java @@ -11,6 +11,11 @@ public interface GitCommandService { */ GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, String accessToken); + /** + * 사용자의 GitHub Access Token을 삭제합니다. + */ + GitResponse.GitHubTokenDeleteResponseDTO deleteGitHubToken(Long memberId); + /** * 팀에 새로운 레포지토리를 추가합니다. */ @@ -21,6 +26,11 @@ public interface GitCommandService { */ GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long repositoryId); + /** + * 레포지토리를 삭제합니다. + */ + GitResponse.RepositoryDeleteResponseDTO deleteRepository(Long repositoryId); + /** * GitHub Token 만료 시 처리합니다 (API 401 에러 감지). * token을 초기화하여 사용자가 재인증하도록 유도합니다. diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index 88e0757..0ab8f94 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.decision.repository.ApplicationCommitsRepository; +import com.whylog.server.domain.decision.repository.DecisionCommitsRepository; import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.domain.git.dto.GitResponse; import com.whylog.server.domain.git.entity.CommitAnalysis; @@ -58,6 +60,8 @@ public class GitCommandServiceImpl implements GitCommandService { private final RepositoryRepository repositoryRepository; private final CommitRepository commitRepository; private final CommitAnalysisRepository commitAnalysisRepository; + private final DecisionCommitsRepository decisionCommitsRepository; + private final ApplicationCommitsRepository applicationCommitsRepository; private final FastApiCommitClient fastApiCommitClient; private final TeamUseCase teamUseCase; private final MemberUseCase memberUseCase; @@ -83,6 +87,17 @@ public GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, Str .build(); } + @Override + @Transactional + public GitResponse.GitHubTokenDeleteResponseDTO deleteGitHubToken(Long memberId) { + if (memberId == null) throw new ParameterRequiredException(); + + Member member = memberUseCase.findMemberById(memberId); + member.clearGithubToken(); + + return GitResponse.GitHubTokenDeleteResponseDTO.from(true); + } + /** * 팀에 새로운 레포지토리를 추가합니다. */ @@ -104,7 +119,8 @@ public Repository createRepository(Long memberId, Long teamId, GitRequest.Reposi // 레포 URL 유효성 검증 및 GitHub에서 존재 여부 확인 GitHubUtil.validateRepositoryExists(request.getUrl(), member.getGithubAccessToken()); } catch (ErrorHandler e) { - if (e.getCode() == GitErrorCode.GITHUB_TOKEN_EXPIRED) { + if (e.getCode() == GitErrorCode.GITHUB_TOKEN_EXPIRED + || e.getCode() == GitErrorCode.GITHUB_TOKEN_INVALID) { invalidateGitHubToken(memberId); } throw e; @@ -160,6 +176,32 @@ public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long .build(); } + @Override + @Transactional + public GitResponse.RepositoryDeleteResponseDTO deleteRepository(Long repositoryId) { + if (repositoryId == null) throw new ParameterRequiredException(); + + Repository repository = repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + List commitIds = commitRepository.findIdsByRepositoryId(repositoryId); + + if (!commitIds.isEmpty()) { + List decisionCommitIds = decisionCommitsRepository.findIdsByCommitIdIn(commitIds); + if (!decisionCommitIds.isEmpty()) { + applicationCommitsRepository.deleteByDecisionCommitsIdIn(decisionCommitIds); + decisionCommitsRepository.deleteByCommitIdIn(commitIds); + } + } + + repositoryRepository.delete(repository); + + return GitResponse.RepositoryDeleteResponseDTO.builder() + .repositoryId(repositoryId) + .isRemoved(true) + .build(); + } + /** * GitHub에서 마지막 동기화 시간 이후의 커밋을 조회하고 DB에 일괄 저장합니다. */ diff --git a/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java b/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java index 6406084..e77b6f6 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java @@ -14,6 +14,11 @@ public interface GitQueryService { */ List getRepositories(Long teamId); + /** + * 사용자의 GitHub Access Token 등록 여부를 조회합니다. + */ + GitResponse.GitHubTokenStatusResponseDTO getGitHubTokenStatus(Long memberId); + /** * 특정 커밋을 조회합니다. (변경된 파일 정보는 GitHub API에서 살시간으로 조회) */ @@ -25,4 +30,6 @@ public interface GitQueryService { */ Slice getCommitsByRepository(Long repositoryId, Long cursorId); + GitResponse.CommitListResponseDTO getCommitListResponse(Long repositoryId, Long cursorId); + } diff --git a/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java index 7e40fcf..7942242 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java @@ -1,6 +1,8 @@ package com.whylog.server.domain.git.service; +import com.whylog.server.domain.git.repository.CommitConnectionRepository; import com.whylog.server.domain.git.dto.GitResponse; import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.repository.CommitAnalysisRepository; import com.whylog.server.domain.git.entity.Repository; import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.domain.git.exception.RepositoryNotFoundException; @@ -24,7 +26,9 @@ import org.springframework.transaction.annotation.Transactional; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -36,6 +40,8 @@ public class GitQueryServiceImpl implements GitQueryService { private final RepositoryRepository repositoryRepository; private final CommitRepository commitRepository; + private final CommitAnalysisRepository commitAnalysisRepository; + private final CommitConnectionRepository commitConnectionRepository; private final MemberUseCase memberUseCase; private final TeamUseCase teamUseCase; @@ -53,6 +59,14 @@ public List getRepositories(Long teamId) { return repositoryRepository.findByTeamId(teamId); } + @Override + public GitResponse.GitHubTokenStatusResponseDTO getGitHubTokenStatus(Long memberId) { + if (memberId == null) throw new ParameterRequiredException(); + + Member member = memberUseCase.findMemberById(memberId); + return GitResponse.GitHubTokenStatusResponseDTO.from(member.hasGithubToken()); + } + /** * 커서 기반 무한스크롤 - 커밋 목록 조회 * cursorId가 null이면 첫 페이지, 있으면 다음 페이지 @@ -68,6 +82,31 @@ public Slice getCommitsByRepository(Long repositoryId, Long cursorId) { return commitRepository.findCommitsWithCursor(repositoryId, cursorId, pageable); } + @Override + public GitResponse.CommitListResponseDTO getCommitListResponse(Long repositoryId, Long cursorId) { + // 커밋 페이지를 조회한 뒤, 각 커밋에 연결된 적용사항을 함께 응답 DTO로 조립 + Slice commitSlice = getCommitsByRepository(repositoryId, cursorId); + + List commits = commitSlice.getContent(); + Map connectedApplicationsByCommitId = new LinkedHashMap<>(); + + if (!commits.isEmpty()) { + List commitIds = commits.stream() + .map(Commit::getId) + .toList(); + + // 현재 페이지의 커밋들에 연결된 적용사항을 한 번에 조회해 commitId 기준으로 매핑 + commitConnectionRepository.findByCommitIds(commitIds).forEach(commitConnection -> + connectedApplicationsByCommitId.put( + commitConnection.getCommit().getId(), + GitResponse.CommitDTO.ConnectedApplicationDTO.from(commitConnection.getApplication()) + ) + ); + } + + return GitResponse.CommitListResponseDTO.from(commitSlice, cursorId, connectedApplicationsByCommitId); + } + /** * 특정 커밋을 조회합니다. (변경된 파일 정보는 GitHub API를 통해 살시간으로 조회) */ @@ -80,6 +119,9 @@ public GitResponse.CommitDetailDTO getCommitByHash(Long memberId, Long repositor // DB에서 커밋 정보 조회 Commit commit = commitRepository.findByRepositoryIdAndHash(repositoryId, hash) .orElseThrow(() -> new ErrorHandler(GitErrorCode.COMMIT_NOT_FOUND)); + String summary = commitAnalysisRepository.findByCommitId(commit.getId()) + .map(analysis -> analysis.getSummary()) + .orElse(null); // 사용자의 GitHub Token 조회 Member member = memberUseCase.findMemberById(memberId); @@ -118,6 +160,6 @@ public GitResponse.CommitDetailDTO getCommitByHash(Long memberId, Long repositor } // DB에 저장된 정보와 GitHub API에서 조회한 파일 정보를 포함해서 반환 - return GitResponse.CommitDetailDTO.of(commit, changedFiles); + return GitResponse.CommitDetailDTO.of(commit, summary, changedFiles); } } diff --git a/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java index abfd161..e775d22 100644 --- a/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java +++ b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java @@ -68,7 +68,7 @@ public static void validateRepositoryExists(String repositoryUrl, String accessT * 사용자 정보 조회를 시도하여 token이 유효한지 확인합니다. * * @param accessToken 검증할 GitHub access token - * @throws ErrorHandler token이 유효하지 않으면 GITHUB_TOKEN_EXPIRED 예외 발생 + * @throws ErrorHandler token이 유효하지 않으면 GITHUB_TOKEN_INVALID 예외 발생 */ public static void validateGitHubToken(String accessToken) { try { @@ -77,17 +77,17 @@ public static void validateGitHubToken(String accessToken) { gitHub.getMyself(); log.info("GitHub token 유효성 검증 성공"); } catch (HttpException e) { - // 401 Unauthorized: token 만료 또는 유효하지 않음 + // 401 Unauthorized: 잘못된 토큰이거나 더 이상 유효하지 않은 토큰 if (e.getResponseCode() == 401) { log.warn("GitHub token 유효하지 않음 (401 Unauthorized)"); - throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_INVALID); } // 다른 HTTP 에러 log.error("GitHub API 에러 (상태코드: {}): {}", e.getResponseCode(), e.getMessage()); throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); } catch (IOException e) { log.error("GitHub token 검증 중 네트워크 에러: {}", e.getMessage()); - throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); } }