diff --git a/build.gradle b/build.gradle index 3a0f8e7..1bba461 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // GitHub API + implementation 'org.kohsuke:github-api:1.321' + } tasks.named('test') { 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 a916f6b..d75616d 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,18 +2,18 @@ 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.service.GitCommandService; +import com.whylog.server.domain.git.service.GitQueryService; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - +import org.springframework.data.domain.Slice; +import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @@ -22,39 +22,122 @@ @Tag(name = "Git", description = "깃 관련 API") public class GitController { + private final GitCommandService gitCommandService; + private final GitQueryService gitQueryService; + + @PostMapping("/github/token") + @Operation( + summary = "GitHub Access Token 등록 API", + description = """ + GitHub API를 사용하기 위한 Personal Access Token을 등록하는 API입니다. + - 사용자가 GitHub에서 발급한 Personal Access Token을 저장합니다. + - 저장된 토큰은 이후 레포지토리 동기화 시 자동으로 사용됩니다. + - GitHub Personal Access Token 발급 필요 (repo 권한 포함) + - GitHub Settings > Developer settings > Personal access tokens에서 발급 + """) + public ApiResponse registerGitHubToken( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody GitRequest.GitHubTokenDTO request) { + return ApiResponse.onSuccess(gitCommandService.registerGitHubToken(memberId, request.getAccessToken())); + } + @GetMapping("/teams/{teamId}/repositories") - @Operation(summary = "레포 목록 조회 API", description = "특정 팀의 레포 목록을 조회하는 API입니다.") + @Operation( + summary = "팀의 연동된 레포지토리 목록 조회", + description = """ + 팀에 연동된 GitHub 레포지토리 목록을 조회합니다.(페이징 없음) + + 정렬 순서: + 1. 최근 동기화한 레포지토리 (동기화 시간 최신순) + 2. 동기화된 적 없는 레포지토리 (추가한순) + """) public ApiResponse> getRepositories( @PathVariable Long teamId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess( + gitQueryService.getRepositories(teamId).stream() + .map(GitResponse.RepositoryDTO::from) + .toList() + ); } @PostMapping("/teams/{teamId}/repositories") - @Operation(summary = "레포 추가 API", description = "팀에 새로운 레포를 추가하는 API입니다.") + @Operation( + summary = "GitHub 레포지토리 추가", + description = "GitHub 레포지토리를 팀에 연동합니다. 등록 시에는 레포 정보만 저장되며, 커밋은 동기화 API를 호출할 때 수집됩니다.") public ApiResponse createRepository( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody GitRequest.RepositoryCreateDTO request) { - return ApiResponse.onSuccess(null); + var repository = gitCommandService.createRepository(memberId, teamId, request); + return ApiResponse.onSuccess(GitResponse.RepositoryCreateResponseDTO.from(repository)); } - @GetMapping("/repositories/{repositoryId}/commits") - @Operation(summary = "커밋 목록 조회 API", description = "특정 레포의 커밋 목록을 조회하는 API입니다.") - public ApiResponse> getCommits( + @PostMapping("/repositories/{repositoryId}/sync") + @Operation( + summary = "GitHub 레포지토리 동기화", + description = "등록된 레포지토리의 최신 커밋을 DB에 저장합니다. 마지막 동기화 이후의 새 커밋만 저장되며 Merge 커밋은 제외됩니다.") + public ApiResponse syncRepository( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long repositoryId) { - return ApiResponse.onSuccess(null); + + gitCommandService.syncRepository(memberId, repositoryId); + return ApiResponse.onSuccess(GitResponse.RepositorySyncResponseDTO.from(repositoryId)); } - @GetMapping("/commits/{commitId}") - @Operation(summary = "커밋 상세 조회 API", description = "특정 커밋의 상세 정보를 조회하는 API입니다.") - public ApiResponse getCommitDetail( - @PathVariable Long commitId) { - return ApiResponse.onSuccess(null); + @GetMapping("/repositories/{repositoryId}/commits") + @Operation( + summary = "커밋 목록 조회 (커서 기반 무한스크롤)", + description = """ + 10개씩 커밋을 조회합니다. + + 📌 사용 방법: + 1. 첫 요청: cursor 파라미터 없음 + 2. 응답의 hasNext가 true면, nextCursorId를 cursor로 다시 요청 + 3. hasNext가 false가 나올 때까지 반복 + + 💡 응답 필드: + - hasNext: 다음 페이지 존재 여부 (더 불러올 커밋이 있으면 true) + - nextCursorId: 다음 요청에 사용할 커서 ID + - isFirst: 첫 페이지 여부 + """) + 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)); } - @PostMapping("/repositories/{repositoryId}/sync") - @Operation(summary = "레포 동기화 API", description = "레포를 동기화하여 최신 커밋 정보를 가져오는 API입니다.") - public ApiResponse syncRepository( - @PathVariable Long repositoryId) { - return ApiResponse.onSuccess(null); + @GetMapping("/repositories/{repositoryId}/commits/{commitHash}") + @Operation( + summary = "커밋 상세 조회", + description = """ + 커밋의 전체 정보와 변경된 파일 목록을 조회합니다. + + - DB에서 가져오는 정보: hash, message, authorName, authorEmail, authorProfileImage, dateTime, description, changedFileCount + - (참고) authorName, authorEmail, authorProfileImage는 깃허브에서 가져와서 저장한 정보입니다. + - GitHub API에서 실시간 조회: changedFileList (변경된 파일 및 코드) + + ⚙️ 처리 방식: + - GitHub API 호출 완료 후 응답 (비동기 아님) + - GitHub API 호출 실패 시 변경 파일 리스트는 빈 배열로 반환 + + 💡 프론트 구현시 참고 사항: + - changedCode를 react-diff-viewer-continued 라이브러리 사용하면 될거같으니 참고해주세요! + """) + public ApiResponse getCommitDetail( + @Parameter(hidden = true) @CurrentMember Long memberId, + @PathVariable Long repositoryId, + @PathVariable String commitHash) { + GitResponse.CommitDetailDTO commitDetail = gitQueryService.getCommitByHash(memberId, repositoryId, commitHash); + return ApiResponse.onSuccess(commitDetail); } } + + + diff --git a/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java b/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java index a17ef37..785b98a 100644 --- a/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java +++ b/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java @@ -1,10 +1,13 @@ package com.whylog.server.domain.git.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.*; import org.hibernate.validator.constraints.URL; +import java.time.LocalDateTime; + public class GitRequest { @Getter @@ -23,5 +26,49 @@ public static class RepositoryCreateDTO { private String url; } + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "GitHub Access Token 등록 요청") + public static class GitHubTokenDTO { + + @Schema(description = "GitHub Access Token", example = "ghp_jv******") + @NotBlank + @JsonProperty("access_token") + private String accessToken; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "커밋 생성 DTO") + public static class CommitCreateDTO { + + @Schema(description = "커밋 해시", example = "abc123def456") + private String hash; + + @Schema(description = "커밋 메시지", example = "[feat] 사용자 인증 기능 추가") + private String message; + + @Schema(description = "작성자 이름", example = "김준용") + private String authorName; + + @Schema(description = "작성자 이메일", example = "user@example.com") + private String authorEmail; + + @Schema(description = "작성자 프로필 이미지", example = "https://avatars.githubusercontent.com/u/123?v=4") + private String authorProfileImage; + + @Schema(description = "커밋 날짜", example = "2026-03-24T10:30:00") + private LocalDateTime dateTime; + + @Schema(description = "추가된 줄 수", example = "45") + private Integer addedLines; + + @Schema(description = "삭제된 줄 수", example = "12") + private Integer deletedLines; + } } 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 7b3ff0f..ac34fd0 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,12 +1,16 @@ package com.whylog.server.domain.git.dto; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Slice; import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Collectors; public class GitResponse { @@ -25,6 +29,14 @@ public static class RepositoryDTO { @Schema(description = "마지막 동기화 시간", example = "2026-03-25T10:30:00") private LocalDateTime lastSyncDateTime; + + public static RepositoryDTO from(Repository repository) { + return RepositoryDTO.builder() + .repositoryId(repository.getId()) + .name(repository.getName()) + .lastSyncDateTime(repository.getLastSyncedAt()) + .build(); + } } @Getter @@ -42,6 +54,14 @@ public static class RepositoryCreateResponseDTO { @Schema(description = "레포 URL", example = "https://github.com/WhyLog-App/WhyLog-BE") private String url; + + public static RepositoryCreateResponseDTO from(Repository repository) { + return RepositoryCreateResponseDTO.builder() + .repositoryId(repository.getId()) + .name(repository.getName()) + .url(repository.getUrl()) + .build(); + } } @Getter @@ -71,6 +91,18 @@ public static class CommitDTO { @Schema(description = "삭제된 줄 수", example = "12") private Integer deletedLines; + + public static CommitDTO from(Commit commit) { + return CommitDTO.builder() + .commitId(commit.getId()) + .hash(commit.getHash()) + .message(commit.getMessage()) + .authorName(commit.getAuthorName()) + .dateTime(commit.getDateTime()) + .addedLines(commit.getAddedLines()) + .deletedLines(commit.getDeletedLines()) + .build(); + } } @Getter @@ -95,15 +127,36 @@ public static class CommitDetailDTO { @Schema(description = "작성자 이메일", example = "user@example.com") private String authorEmail; + @Schema(description = "작성자 프로필 사진", example = "https://img.com/profile.jpg") + private String authorProfileImage; + @Schema(description = "커밋 날짜", example = "2026-03-24T10:30:00") private LocalDateTime dateTime; @Schema(description = "설명", example = "사용자 마이페이지 조회 및 수정 API를 RESTful 방식으로 구현했습니다.") private String description; + @Schema(description = "변경된 파일 개수", example = "2") + private Integer changedFileCount; + @Schema(description = "변경된 파일 목록") private List changedFileList; + public static CommitDetailDTO of(Commit commit, List changedFileList) { + return CommitDetailDTO.builder() + .commitId(commit.getId()) + .hash(commit.getHash()) + .message(commit.getMessage()) + .authorName(commit.getAuthorName()) + .authorEmail(commit.getAuthorEmail()) + .authorProfileImage(commit.getAuthorProfileImage()) + .dateTime(commit.getDateTime()) + .description(commit.getMessage()) //TODO: AI 요약값으로 변경 + .changedFileCount(changedFileList.size()) + .changedFileList(changedFileList) + .build(); + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -116,6 +169,12 @@ public static class ChangedFileDTO { @Schema(description = "변경된 코드", example = "+ export const getMyPage = async (req, res) => {") private String changedCode; + + @Schema(description = "추가된 줄 수", example = "5") + private Integer addedLines; + + @Schema(description = "삭제된 줄 수", example = "2") + private Integer deletedLines; } } @@ -128,5 +187,70 @@ public static class RepositorySyncResponseDTO { @Schema(description = "레포 ID", example = "1") private Long repositoryId; + + public static RepositorySyncResponseDTO from(Long repositoryId) { + return RepositorySyncResponseDTO.builder() + .repositoryId(repositoryId) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "깃허브 Access Token 등록 응답") + public static class GitHubTokenResponseDTO { + + @Schema(description = "GitHub Access Token", example = "ghp_jv******") + private String accessToken; + + public static GitHubTokenResponseDTO from(String accessToken) { + return GitHubTokenResponseDTO.builder() + .accessToken(accessToken) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "커서 기반 무한스크롤 커밋 목록 응답") + public static class CommitListResponseDTO { + + @Schema(description = "커밋 목록") + private List commitDTOList; + + @Schema(description = "현재 페이지의 커밋 개수", example = "10") + private Integer commitListSize; + + @Schema(description = "페이지 처음 여부", example = "true") + private Boolean isFirst; + + @Schema(description = "다음 페이지가 있는지 여부", example = "true") + private Boolean hasNext; + + @Schema(description = "다음 커서 ID (무한스크롤용)", example = "1") + private Long nextCursorId; + + public static CommitListResponseDTO from(Slice commitSlice, Long cursorId) { + List commitDTOs = commitSlice.getContent().stream() + .map(CommitDTO::from) + .collect(Collectors.toList()); + + // 다음 커서 ID 설정 + Long nextCursorId = commitSlice.hasNext() && !commitDTOs.isEmpty() + ? commitDTOs.get(commitDTOs.size() - 1).getCommitId() + : null; + + return CommitListResponseDTO.builder() + .commitDTOList(commitDTOs) + .commitListSize(commitDTOs.size()) + .isFirst(cursorId == null) + .hasNext(commitSlice.hasNext()) + .nextCursorId(nextCursorId) + .build(); + } } } 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 040f868..65b7fc1 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 @@ -1,24 +1,14 @@ package com.whylog.server.domain.git.entity; +import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.global.entity.BaseEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Entity @Getter @Table(name = "Commits") @@ -34,10 +24,10 @@ public class Commit extends BaseEntity { @JoinColumn(name = "repository_id", nullable = false) private Repository repository; - @Column(length = 255, nullable = false) + @Column(nullable = false) private String hash; - @Column(length = 255, nullable = false) + @Column(nullable = false) private String message; @Column(name = "author_name", length = 50, nullable = false) @@ -46,9 +36,18 @@ public class Commit extends BaseEntity { @Column(name = "author_email", length = 50, nullable = false) private String authorEmail; + @Column(name = "author_profile_image", length = 255, nullable = false) + private String authorProfileImage; + @Column(name = "datetime", nullable = false) private LocalDateTime dateTime; + @Column(name = "added_lines", nullable = false) + private Integer addedLines; + + @Column(name = "deleted_lines", nullable = false) + private Integer deletedLines; + // @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) // private final List changedFiles = new ArrayList<>(); // @@ -57,4 +56,18 @@ public class Commit extends BaseEntity { // // @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(); + commit.hash = dto.getHash(); + commit.message = dto.getMessage(); + commit.authorName = dto.getAuthorName(); + commit.authorEmail = dto.getAuthorEmail(); + commit.authorProfileImage = dto.getAuthorProfileImage(); + commit.dateTime = dto.getDateTime(); + commit.addedLines = dto.getAddedLines(); + commit.deletedLines = dto.getDeletedLines(); + commit.repository = repository; + return 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 4cfabb4..eb55d9c 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 @@ -13,6 +13,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -30,10 +31,34 @@ public class Repository extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(length = 255, nullable = false) + private String name; + + @Column(length = 500, nullable = false) + private String url; + + @Column(nullable = true) + private LocalDateTime lastSyncedAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; // @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(); + repository.name = name; + repository.url = url; + repository.team = team; + return repository; + } + + /** + * 마지막 동기화 시간을 업데이트합니다. + */ + public void updateLastSyncedAt(LocalDateTime syncedAt) { + this.lastSyncedAt = syncedAt; + } } 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 new file mode 100644 index 0000000..02e1c10 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java @@ -0,0 +1,43 @@ +package com.whylog.server.domain.git.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GitErrorCode implements BaseErrorCode { + REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_1", "존재하지 않는 레포지토리입니다."), + 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이 등록되지 않았습니다."), + 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이 만료되었습니다. 다시 인증해주세요."), + GITHUB_API_ERROR(HttpStatus.BAD_GATEWAY, "GIT_502_1", "GitHub API 호출 중 오류가 발생했습니다."),; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java b/src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java new file mode 100644 index 0000000..2cf3d54 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java @@ -0,0 +1,11 @@ +package com.whylog.server.domain.git.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class GitTokenNotRegisteredException extends GeneralException { + + public GitTokenNotRegisteredException() { + super(GitErrorCode.GITHUB_TOKEN_NOT_REGISTERED); + } + +} diff --git a/src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java b/src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java new file mode 100644 index 0000000..8930368 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java @@ -0,0 +1,9 @@ +package com.whylog.server.domain.git.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class RepositoryNotFoundException extends GeneralException { + + public RepositoryNotFoundException() {super(GitErrorCode.REPOSITORY_NOT_FOUND);} + +} 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 new file mode 100644 index 0000000..5db73b6 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java @@ -0,0 +1,46 @@ +package com.whylog.server.domain.git.repository; + +import com.whylog.server.domain.git.entity.Commit; +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.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface CommitRepository extends JpaRepository { + + // 레포지토리 ID와 해시로 커밋 조회 + Optional findByRepositoryIdAndHash(Long repositoryId, String hash); + + // 해시 조회 + @Query("SELECT c.hash FROM Commit c " + + "WHERE c.repository.id = :repositoryId " + + "AND c.hash IN :hashes") + Set findExistingHashes( + @Param("repositoryId") Long repositoryId, + @Param("hashes") List hashes + ); + + // 커서 기반 무한스크롤 - 커밋 목록 조회 + @Query("SELECT c FROM Commit c " + + "WHERE c.repository.id = :repositoryId " + + "AND (" + + " :cursorId IS NULL " + + " OR c.dateTime < (SELECT sub.dateTime FROM Commit sub WHERE sub.id = :cursorId) " + + " OR (c.dateTime = (SELECT sub2.dateTime FROM Commit sub2 WHERE sub2.id = :cursorId) AND c.id < :cursorId)" + + ") " + + "ORDER BY c.dateTime DESC, c.id DESC") + Slice findCommitsWithCursor( + @Param("repositoryId") Long repositoryId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} + + diff --git a/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java b/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java new file mode 100644 index 0000000..8cbf8c5 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java @@ -0,0 +1,16 @@ +package com.whylog.server.domain.git.repository; + +import com.whylog.server.domain.git.entity.Repository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; + +public interface RepositoryRepository extends JpaRepository { + + // 팀의 레포지토리를 최근 동기화 시간 기준 내림차순으로 조회 + @Query("SELECT r FROM Repository r " + + "WHERE r.team.id = :teamId " + + "ORDER BY r.lastSyncedAt DESC NULLS LAST, r.id DESC") + List findByTeamId(@Param("teamId") Long teamId); +} 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 new file mode 100644 index 0000000..10a1ad2 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java @@ -0,0 +1,29 @@ +package com.whylog.server.domain.git.service; + +import com.whylog.server.domain.git.dto.GitRequest; +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Repository; + +public interface GitCommandService { + + /** + * 사용자의 GitHub Access Token을 등록합니다. + */ + GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, String accessToken); + + /** + * 팀에 새로운 레포지토리를 추가합니다. + */ + Repository createRepository(Long memberId, Long teamId, GitRequest.RepositoryCreateDTO request); + + /** + * 레포지토리를 동기화합니다. + */ + GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long repositoryId); + + /** + * GitHub Token 만료 시 처리합니다 (API 401 에러 감지). + * token을 초기화하여 사용자가 재인증하도록 유도합니다. + */ + void invalidateGitHubToken(Long memberId); +} 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 new file mode 100644 index 0000000..97c991d --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -0,0 +1,219 @@ +package com.whylog.server.domain.git.service; + +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.entity.Repository; +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; +import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.domain.git.repository.CommitRepository; +import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.util.github.GitHubUtil; +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.service.TeamUseCase; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GHCommit; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.HttpException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GitCommandServiceImpl implements GitCommandService { + + private final RepositoryRepository repositoryRepository; + private final CommitRepository commitRepository; + private final TeamUseCase teamUseCase; + private final MemberUseCase memberUseCase; + + /** + * 사용자의 GitHub Access Token을 등록합니다. + * 등록 전에 token의 유효성을 검증합니다. + */ + @Override + @Transactional + public GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, String accessToken) { + if (accessToken == null || accessToken.isEmpty()) throw new ParameterRequiredException(); + + // GitHub token 유효성 검증 (401 응답 시 예외 발생) + GitHubUtil.validateGitHubToken(accessToken); + + Member member = memberUseCase.findMemberById(memberId); + member.setGithubAccessToken(accessToken); + + return GitResponse.GitHubTokenResponseDTO.builder() + .accessToken(accessToken) + .build(); + } + + /** + * 팀에 새로운 레포지토리를 추가합니다. + */ + @Override + @Transactional + public Repository createRepository(Long memberId, Long teamId, GitRequest.RepositoryCreateDTO request) { + if (teamId == null) throw new ParameterRequiredException(); + + // 현재 사용자의 GitHub Token 조회 + Member member = memberUseCase.findMemberById(memberId); + + // token 존재 및 유효성 검증 + if (!member.hasGithubToken()) { + throw new GitTokenNotRegisteredException(); + } + + try { + GitHubUtil.validateGitHubToken(member.getGithubAccessToken()); + // 레포 URL 유효성 검증 및 GitHub에서 존재 여부 확인 + GitHubUtil.validateRepositoryExists(request.getUrl(), member.getGithubAccessToken()); + } catch (ErrorHandler e) { + if (e.getCode() == GitErrorCode.GITHUB_TOKEN_EXPIRED) { + invalidateGitHubToken(memberId); + } + throw e; + } + + Team team = teamUseCase.findTeamById(teamId); + Repository repository = Repository.create(request.getName(), request.getUrl(), team); + return repositoryRepository.save(repository); + } + + /** + * 레포지토리를 동기화합니다 (커밋 저장) + */ + @Override + @Transactional + public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long repositoryId) { + if (memberId == null) throw new ParameterRequiredException(); + if (repositoryId == null) throw new ParameterRequiredException(); + + // 사용자의 GitHub Token 조회 및 검증 + Member member = memberUseCase.findMemberById(memberId); + if (!member.hasGithubToken()) { + throw new GitTokenNotRegisteredException(); + } + + Repository repository = repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + try { + GitHub gitHub = GitHubUtil.createGitHubInstance(member.getGithubAccessToken()); + String repoPath = GitHubUtil.extractRepoPath(repository.getUrl()); + GHRepository ghRepository = gitHub.getRepository(repoPath); + + // 마지막 동기화 시간 이후의 커밋만 저장 + LocalDateTime lastSyncedAt = repository.getLastSyncedAt(); + syncCommits(ghRepository, repository, lastSyncedAt); + + // 동기화 시간 업데이트 + repository.updateLastSyncedAt(LocalDateTime.now()); + + } catch (HttpException e) { + if (e.getResponseCode() == 401) { + invalidateGitHubToken(memberId); // DB에서 토큰 삭제 + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + GitHubUtil.handleHttpException(e); + } catch (IOException e) { + throw new RepositoryNotFoundException(); + } + + return GitResponse.RepositorySyncResponseDTO.builder() + .repositoryId(repositoryId) + .build(); + } + + /** + * GitHub에서 마지막 동기화 시간 이후의 커밋을 조회하고 DB에 일괄 저장합니다. + */ + private void syncCommits(GHRepository ghRepository, Repository repository, LocalDateTime lastSyncedAt) throws IOException { + + var commitQuery = ghRepository.queryCommits(); + + if (lastSyncedAt != null) { + Date sinceDate = Date.from( + lastSyncedAt.atZone(ZoneId.systemDefault()).toInstant() + ); + commitQuery.since(sinceDate); + } + + List allGitHubCommits = commitQuery.list().toList(); + + List allHashes = allGitHubCommits.stream() + .map(GHCommit::getSHA1) + .toList(); + + Set existingHashes = allHashes.isEmpty() + ? Collections.emptySet() + : commitRepository.findExistingHashes(repository.getId(), allHashes); + + List newCommits = allGitHubCommits.stream() + .filter(ghCommit -> !existingHashes.contains(ghCommit.getSHA1())) + .map(ghCommit -> { + try { + var shortInfo = ghCommit.getCommitShortInfo(); + String message = shortInfo.getMessage(); + + if (message.startsWith("Merge pull request")) return null; + + String authorProfileImage = (ghCommit.getAuthor() != null) + ? ghCommit.getAuthor().getAvatarUrl() : null; + + GitRequest.CommitCreateDTO dto = GitRequest.CommitCreateDTO.builder() + .hash(ghCommit.getSHA1()) + .message(message) + .authorName(shortInfo.getAuthor().getName()) + .authorEmail(shortInfo.getAuthor().getEmail()) + .authorProfileImage(authorProfileImage) + .dateTime(shortInfo.getAuthor().getDate() + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime()) + .addedLines(ghCommit.getLinesAdded()) + .deletedLines(ghCommit.getLinesDeleted()) + .build(); + + return Commit.create(dto, repository); + } catch (Exception e) { + log.warn("커밋 변환 실패 [{}]: {}", ghCommit.getSHA1(), e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + + if (!newCommits.isEmpty()) { + commitRepository.saveAll(newCommits); + log.info("새로운 커밋 {}개 동기화 완료: {}", newCommits.size(), ghRepository.getFullName()); + } else { + log.info("새로 동기화할 커밋이 없습니다: {}", ghRepository.getFullName()); + } + } + + /** + * GitHub Token 만료 시 처리합니다 (API 401 에러 감지) + * token을 초기화하여 사용자가 재인증하도록 유도합니다. + */ + @Override + @Transactional + public void invalidateGitHubToken(Long memberId) { + if (memberId == null) throw new ParameterRequiredException(); + + Member member = memberUseCase.findMemberById(memberId); + member.clearGithubToken(); + } +} 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 new file mode 100644 index 0000000..6406084 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java @@ -0,0 +1,28 @@ +package com.whylog.server.domain.git.service; + +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public interface GitQueryService { + + /** + * 특정 팀의 레포지토리 목록을 조회합니다. + */ + List getRepositories(Long teamId); + + /** + * 특정 커밋을 조회합니다. (변경된 파일 정보는 GitHub API에서 살시간으로 조회) + */ + GitResponse.CommitDetailDTO getCommitByHash(Long memberId, Long repositoryId, String hash); + + /** + * 커서 기반 무한스크롤 - 커밋 목록 조회 (페이지 크기 고정: 10) + * cursorId가 null이면 첫 페이지, 있으면 다음 페이지 + */ + Slice getCommitsByRepository(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 new file mode 100644 index 0000000..7e40fcf --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java @@ -0,0 +1,123 @@ +package com.whylog.server.domain.git.service; +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; +import com.whylog.server.domain.git.repository.CommitRepository; +import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.global.util.github.GitHubUtil; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.domain.team.service.TeamUseCase; +import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GHCommit; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class GitQueryServiceImpl implements GitQueryService { + + private static final int PAGE_SIZE = 10; + + private final RepositoryRepository repositoryRepository; + private final CommitRepository commitRepository; + private final MemberUseCase memberUseCase; + private final TeamUseCase teamUseCase; + + /** + * 특정 팀의 레포지토리 목록을 조회합니다. + */ + public List getRepositories(Long teamId) { + + // null 체크 + if (teamId == null) throw new ParameterRequiredException(); + + // 팀 존재 여부 검증 + teamUseCase.findTeamById(teamId); + + return repositoryRepository.findByTeamId(teamId); + } + + /** + * 커서 기반 무한스크롤 - 커밋 목록 조회 + * cursorId가 null이면 첫 페이지, 있으면 다음 페이지 + * 페이지 크기 고정: 10 + */ + public Slice getCommitsByRepository(Long repositoryId, Long cursorId) { + + // 레포 확인 + repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + return commitRepository.findCommitsWithCursor(repositoryId, cursorId, pageable); + } + + /** + * 특정 커밋을 조회합니다. (변경된 파일 정보는 GitHub API를 통해 살시간으로 조회) + */ + public GitResponse.CommitDetailDTO getCommitByHash(Long memberId, Long repositoryId, String hash) { + // null 체크 + if (memberId == null || repositoryId == null || hash == null) { + throw new ParameterRequiredException(); + } + + // DB에서 커밋 정보 조회 + Commit commit = commitRepository.findByRepositoryIdAndHash(repositoryId, hash) + .orElseThrow(() -> new ErrorHandler(GitErrorCode.COMMIT_NOT_FOUND)); + + // 사용자의 GitHub Token 조회 + Member member = memberUseCase.findMemberById(memberId); + String accessToken = member.getGithubAccessToken(); + + if (accessToken == null || accessToken.isEmpty()) { + throw new GitTokenNotRegisteredException(); + } + + // GitHub API에서 변경된 파일 정보 조회 + List changedFiles = List.of(); + + try { + Repository repository = repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + GitHub gitHub = GitHubUtil.createGitHubInstance(accessToken); + String repoPath = GitHubUtil.extractRepoPath(repository.getUrl()); + GHCommit ghCommit = gitHub.getRepository(repoPath).getCommit(hash); + + // 변경된 파일 정보 조회 및 CommitDetailDTO.ChangedFileDTO로 변환 + changedFiles = ghCommit.listFiles().toList().stream() + .map(file -> GitResponse.CommitDetailDTO.ChangedFileDTO.builder() + .fileName(file.getFileName()) + .changedCode(file.getPatch() != null ? file.getPatch() : "") + .addedLines(file.getLinesAdded()) + .deletedLines(file.getLinesDeleted()) + .build()) + .toList(); + + log.info("GitHub API에서 변경된 파일 정보 조회 완료: {} files", changedFiles.size()); + + } catch (IOException e) { + log.warn("GitHub API 호출 실패 (변경 파일 정보): {}", e.getMessage()); + // GitHub API 실패해도 DB의 커밋 정보는 반환 (파일 정보는 빈 리스트) + } + + // DB에 저장된 정보와 GitHub API에서 조회한 파일 정보를 포함해서 반환 + return GitResponse.CommitDetailDTO.of(commit, changedFiles); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index 4729058..45ce284 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -3,14 +3,8 @@ import com.whylog.server.domain.user.dto.AuthRequest; import com.whylog.server.domain.user.enums.Role; import com.whylog.server.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import com.whylog.server.global.util.crypto.AESCryptoConverter; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -43,6 +37,10 @@ public class Member extends BaseEntity { @Column(nullable = false, length = 20) private Role role; + @Convert(converter = AESCryptoConverter.class) + @Column(name = "github_access_token", length = 500, nullable = true) + private String githubAccessToken; + @Builder private Member(String name, String email, String password, String profileImage, Role role) { this.name = name; @@ -61,4 +59,17 @@ public static Member create(AuthRequest.SignUpDTO dto, String password, Role rol .build(); } + public void setGithubAccessToken(String token) { + this.githubAccessToken = token; + } + + // 토큰 존재 확인 + public boolean hasGithubToken() { + return this.githubAccessToken != null && !this.githubAccessToken.isEmpty(); + } + + // 401 에러시 토큰 삭제 + public void clearGithubToken() { + this.githubAccessToken = null; + } } diff --git a/src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java b/src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java new file mode 100644 index 0000000..ed8a329 --- /dev/null +++ b/src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java @@ -0,0 +1,63 @@ +package com.whylog.server.global.util.crypto; + +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Converter +@Component +public class AESCryptoConverter implements AttributeConverter { + + private static final String ALGORITHM = "AES"; + + @Value("${github.token.encryption.key}") + private String secretKey; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) return null; + + try { + validateKey(); + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + + byte[] encryptedBytes = cipher.doFinal(attribute.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encryptedBytes); + } catch (Exception e) { + throw new ErrorHandler(GitErrorCode.TOKEN_ENCRYPTION_FAILED); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + + try { + validateKey(); + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + + byte[] decodedBytes = Base64.getDecoder().decode(dbData); + return new String(cipher.doFinal(decodedBytes), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new ErrorHandler(GitErrorCode.TOKEN_DECRYPTION_FAILED); + } + } + + private void validateKey() { + if (secretKey == null || secretKey.length() != 32) { + throw new ErrorHandler(GitErrorCode.TOKEN_ENCRYPTION_FAILED); // 키 설정 오류도 암호화 에러로 처리 + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..abfd161 --- /dev/null +++ b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java @@ -0,0 +1,110 @@ +package com.whylog.server.global.util.github; + +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.HttpException; + +import java.io.IOException; + +@Slf4j +public class GitHubUtil { + + // GitHub URL에서 owner/repo 형식 추출 + public static String extractRepoPath(String repositoryUrl) { + String[] parts = repositoryUrl.replace("https://github.com/", "") + .replace(".git", "") + .split("/"); + if (parts.length >= 2) { + return parts[parts.length - 2] + "/" + parts[parts.length - 1]; + } + throw new ErrorHandler(GitErrorCode.INVALID_GITHUB_URL); + } + + + // Access Token으로 GitHub 인스턴스 생성 + public static GitHub createGitHubInstance(String accessToken) { + if (accessToken == null || accessToken.isEmpty()) { + throw new GitTokenNotRegisteredException(); + } + try { + return new GitHubBuilder() + .withOAuthToken(accessToken) + .build(); + } catch (IOException e) { + log.error("GitHub 인스턴스 생성 실패: {}", e.getMessage()); + throw new GitTokenNotRegisteredException(); + } + } + + /** + * GitHub 레포지토리 존재 여부 검증 + * (token 유효성은 호출 전에 validateGitHubToken()으로 먼저 확인되어야 함) + */ + public static void validateRepositoryExists(String repositoryUrl, String accessToken) { + try { + GitHub gitHub = createGitHubInstance(accessToken); + String repoPath = extractRepoPath(repositoryUrl); + gitHub.getRepository(repoPath); + log.info("레포 검증 성공: {}", repoPath); + } catch (HttpException e) { + if (e.getResponseCode() == 404) { + log.warn("레포지토리를 찾을 수 없음: {}", repositoryUrl); + throw new ErrorHandler(GitErrorCode.REPOSITORY_NOT_FOUND); + } + log.error("GitHub API 에러 (상태코드: {}): {}", e.getResponseCode(), e.getMessage()); + throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); + } catch (IOException e) { + log.error("레포 검증 실패: {}", e.getMessage()); + throw new ErrorHandler(GitErrorCode.REPOSITORY_NOT_FOUND); + } + } + + /** + * GitHub Access Token의 유효성을 검증합니다. + * 사용자 정보 조회를 시도하여 token이 유효한지 확인합니다. + * + * @param accessToken 검증할 GitHub access token + * @throws ErrorHandler token이 유효하지 않으면 GITHUB_TOKEN_EXPIRED 예외 발생 + */ + public static void validateGitHubToken(String accessToken) { + try { + GitHub gitHub = createGitHubInstance(accessToken); + // 간단한 API 호출로 token 유효성 확인 (사용자 정보 조회) + gitHub.getMyself(); + log.info("GitHub token 유효성 검증 성공"); + } catch (HttpException e) { + // 401 Unauthorized: token 만료 또는 유효하지 않음 + if (e.getResponseCode() == 401) { + log.warn("GitHub token 유효하지 않음 (401 Unauthorized)"); + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + // 다른 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); + } + } + + /** + * GitHub API 호출 후 HttpException 에러를 처리합니다. + * (동기화 작업 중 발생하는 에러를 통합 처리) + * + * @param e HttpException + * @throws ErrorHandler 적절한 에러 코드와 함께 예외 발생 + */ + public static void handleHttpException(HttpException e) { + if (e.getResponseCode() == 401) { + log.warn("GitHub API 401 Unauthorized - token 만료"); + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + // 다른 HTTP 에러 + log.error("GitHub API 에러 (상태코드: {}): {}", e.getResponseCode(), e.getMessage()); + throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ee8d47f..f553e00 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,3 +50,8 @@ livekit: api-key: ${LIVEKIT_API_KEY:devkey} api-secret: ${LIVEKIT_API_SECRET:01234567890123456789012345678901} token-expire-time: ${LIVEKIT_TOKEN_EXPIRE_TIME:3600000} + +github: + token: + encryption: + key: ${GITHUB_TOKEN_ENCRYPTION_KEY} \ No newline at end of file