Skip to content

Commit d5e5be2

Browse files
authored
Merge pull request #18 from Team-StackUp/feature/us-07-github-repository
feat: GitHub 레포지토리 목록 조회 및 등록
2 parents fb43879 + 11d1141 commit d5e5be2

32 files changed

Lines changed: 1655 additions & 40 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.stackup.stackup.common.retry;
2+
3+
import java.util.Arrays;
4+
5+
public record RetryPolicy(long[] backoffMillis) {
6+
7+
public RetryPolicy {
8+
if (backoffMillis == null) {
9+
throw new IllegalArgumentException("backoffMillis must not be null");
10+
}
11+
for (long delay : backoffMillis) {
12+
if (delay < 0) {
13+
throw new IllegalArgumentException("backoffMillis must be non-negative, got: " + delay);
14+
}
15+
}
16+
backoffMillis = backoffMillis.clone();
17+
}
18+
19+
public static RetryPolicy of(long... backoffMillis) {
20+
return new RetryPolicy(backoffMillis);
21+
}
22+
23+
public static RetryPolicy exponentialBackoff(long initialDelayMillis, int maxAttempts) {
24+
if (initialDelayMillis < 0) {
25+
throw new IllegalArgumentException("initialDelayMillis must be non-negative, got: " + initialDelayMillis);
26+
}
27+
if (maxAttempts < 1) {
28+
throw new IllegalArgumentException("maxAttempts must be at least 1, got: " + maxAttempts);
29+
}
30+
if (maxAttempts > 50) {
31+
throw new IllegalArgumentException("maxAttempts must be at most 50 to avoid overflow, got: " + maxAttempts);
32+
}
33+
long[] backoffMillis = new long[maxAttempts - 1];
34+
for (int i = 0; i < backoffMillis.length; i++) {
35+
backoffMillis[i] = initialDelayMillis * (1L << i);
36+
}
37+
return new RetryPolicy(backoffMillis);
38+
}
39+
40+
public int maxAttempts() {
41+
return backoffMillis.length + 1;
42+
}
43+
44+
public long backoffForAttempt(int attempt) {
45+
return backoffMillis[attempt - 1];
46+
}
47+
48+
@Override
49+
public long[] backoffMillis() {
50+
return backoffMillis.clone();
51+
}
52+
53+
@Override
54+
public boolean equals(Object other) {
55+
return other instanceof RetryPolicy that && Arrays.equals(backoffMillis, that.backoffMillis);
56+
}
57+
58+
@Override
59+
public int hashCode() {
60+
return Arrays.hashCode(backoffMillis);
61+
}
62+
63+
@Override
64+
public String toString() {
65+
return "RetryPolicy" + Arrays.toString(backoffMillis);
66+
}
67+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.stackup.stackup.common.retry;
2+
3+
import java.util.function.Supplier;
4+
5+
import org.jspecify.annotations.NonNull;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.stereotype.Component;
9+
10+
@Component
11+
public class RetryingExecutor {
12+
13+
private static final Logger log = LoggerFactory.getLogger(RetryingExecutor.class);
14+
15+
public void execute(String operationName, RetryPolicy policy, Runnable action) {
16+
execute(operationName, policy, () -> {
17+
action.run();
18+
return null;
19+
});
20+
}
21+
22+
public <T> T execute(String operationName, RetryPolicy policy, Supplier<T> action) {
23+
try {
24+
return runWithRetry(operationName, policy, action);
25+
} catch (RetryInterruptedException interrupted) {
26+
log.error("Interrupted while retrying operation={}", operationName, interrupted);
27+
return null;
28+
} catch (RuntimeException terminalFailure) {
29+
log.error("Exhausted retries for operation={} (attempts={})",
30+
operationName, policy.maxAttempts(), terminalFailure);
31+
return null;
32+
}
33+
}
34+
35+
public <T> T executeOrThrow(String operationName, RetryPolicy policy, Supplier<T> action) {
36+
return runWithRetry(operationName, policy, action);
37+
}
38+
39+
private <T> T runWithRetry(String operationName, @NonNull RetryPolicy policy, Supplier<T> action) {
40+
int maxAttempts = policy.maxAttempts();
41+
RuntimeException lastFailure = null;
42+
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
43+
try {
44+
T result = action.get();
45+
if (attempt > 1) {
46+
log.info("Succeeded operation={} on retry attempt={}", operationName, attempt);
47+
}
48+
return result;
49+
} catch (RuntimeException failure) {
50+
lastFailure = failure;
51+
if (attempt == maxAttempts) {
52+
break;
53+
}
54+
long backoff = policy.backoffForAttempt(attempt);
55+
log.warn("Operation failed name={} attempt={}/{}; retrying in {}ms",
56+
operationName, attempt, maxAttempts, backoff, failure);
57+
if (!sleep(backoff)) {
58+
throw new RetryInterruptedException(operationName, failure);
59+
}
60+
}
61+
}
62+
throw lastFailure;
63+
}
64+
65+
// millis 만큼 sleep합니다
66+
// 테스트 코드에서 이 메서드를 오버라이드하여 sleep을 건너뛸 수 있습니다
67+
protected boolean sleep(long millis) {
68+
if (millis <= 0) {
69+
return true;
70+
}
71+
try {
72+
Thread.sleep(millis);
73+
return true;
74+
} catch (InterruptedException interrupted) {
75+
Thread.currentThread().interrupt();
76+
return false;
77+
}
78+
}
79+
80+
public static final class RetryInterruptedException extends RuntimeException {
81+
public RetryInterruptedException(String operationName, Throwable cause) {
82+
super("Interrupted while retrying operation=" + operationName, cause);
83+
}
84+
}
85+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.stackup.stackup.github.application;
2+
3+
import com.stackup.stackup.common.exception.ApiErrorCode;
4+
import com.stackup.stackup.common.exception.DomainException;
5+
import com.stackup.stackup.github.application.dto.CandidateRepositoryListResult;
6+
import com.stackup.stackup.github.application.dto.CandidateRepositoryResult;
7+
import com.stackup.stackup.github.application.dto.GithubRepositoryResult;
8+
import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand;
9+
import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent;
10+
import com.stackup.stackup.github.domain.GithubRepository;
11+
import com.stackup.stackup.github.domain.GithubRepositoryRepository;
12+
import com.stackup.stackup.github.infrastructure.GithubApiClient;
13+
import com.stackup.stackup.github.infrastructure.GithubTokenCipher;
14+
import com.stackup.stackup.github.infrastructure.dto.GithubRepoListPage;
15+
import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse;
16+
import com.stackup.stackup.user.domain.User;
17+
import com.stackup.stackup.user.domain.UserRepository;
18+
import java.util.HashSet;
19+
import java.util.List;
20+
import java.util.Set;
21+
import java.util.stream.Collectors;
22+
23+
import lombok.RequiredArgsConstructor;
24+
import org.springframework.context.ApplicationEventPublisher;
25+
import org.springframework.data.domain.Page;
26+
import org.springframework.data.domain.Pageable;
27+
import org.springframework.stereotype.Service;
28+
import org.springframework.transaction.annotation.Transactional;
29+
30+
@Service
31+
@Transactional(readOnly = true)
32+
@RequiredArgsConstructor
33+
public class GithubRepositoryService {
34+
35+
private final GithubRepositoryRepository repoRepository;
36+
private final UserRepository userRepository;
37+
private final GithubApiClient githubApiClient;
38+
private final GithubTokenCipher tokenCipher;
39+
private final ApplicationEventPublisher events;
40+
41+
42+
public Page<GithubRepositoryResult> list(Long userId, Pageable pageable) {
43+
return repoRepository.findByUser_IdAndDeletedFalse(userId, pageable)
44+
.map(GithubRepositoryResult::from);
45+
}
46+
47+
public GithubRepositoryResult get(Long userId, Long repositoryId) {
48+
return repoRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId)
49+
.map(GithubRepositoryResult::from)
50+
.orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND));
51+
}
52+
53+
@Transactional
54+
public void delete(Long userId, Long repositoryId) {
55+
GithubRepository repo = repoRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId)
56+
.orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND));
57+
repo.softDelete();
58+
}
59+
60+
@Transactional
61+
public GithubRepositoryResult register(Long userId, RegisterRepositoryCommand command) {
62+
User user = userRepository.findById(userId)
63+
.orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND));
64+
65+
String plainToken = tokenCipher.decrypt(user.getEncryptedGithubAccessToken());
66+
GithubRepoResponse meta = githubApiClient.getRepository(plainToken, command.githubRepoId());
67+
68+
GithubRepository repo = repoRepository.findByUser_IdAndGithubRepoId(userId, command.githubRepoId())
69+
.map(existing -> {
70+
if (!existing.isDeleted()) {
71+
throw new DomainException(ApiErrorCode.REPO_ALREADY_REGISTERED);
72+
}
73+
existing.resurrect(meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch());
74+
return existing;
75+
})
76+
.orElseGet(() -> repoRepository.save(GithubRepository.create(
77+
user,
78+
meta.id(), meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch()
79+
)));
80+
81+
String sealedToken = tokenCipher.encrypt(plainToken);
82+
events.publishEvent(new RepositoryRegisteredEvent(
83+
repo.getId(), userId, repo.getRepoFullName(), repo.getDefaultBranch(), sealedToken
84+
));
85+
86+
return GithubRepositoryResult.from(repo);
87+
}
88+
89+
public CandidateRepositoryListResult listCandidates(Long userId, int page, int perPage) {
90+
User user = userRepository.findById(userId)
91+
.orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND));
92+
93+
String token = tokenCipher.decrypt(user.getEncryptedGithubAccessToken());
94+
GithubRepoListPage listPage = githubApiClient.listUserRepositories(token, page, perPage);
95+
96+
Set<Long> githubIds = listPage.repos().stream()
97+
.map(GithubRepoResponse::id)
98+
.collect(Collectors.toSet());
99+
100+
Set<Long> registered = githubIds.isEmpty()
101+
? Set.of()
102+
: new HashSet<>(repoRepository
103+
.findGithubRepoIdsByUser_IdAndDeletedFalseAndGithubRepoIdIn(userId, githubIds));
104+
105+
List<CandidateRepositoryResult> content = listPage.repos().stream()
106+
.map(r -> new CandidateRepositoryResult(
107+
r.id(), r.name(), r.fullName(), r.htmlUrl(), r.defaultBranch(),
108+
r.privateRepo(), r.description(), registered.contains(r.id())
109+
))
110+
.toList();
111+
112+
return new CandidateRepositoryListResult(content, page, perPage, listPage.hasNext());
113+
}
114+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.stackup.stackup.github.application.dto;
2+
3+
import java.util.List;
4+
5+
public record CandidateRepositoryListResult(
6+
List<CandidateRepositoryResult> content,
7+
int page,
8+
int perPage,
9+
boolean hasNext
10+
) {
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.stackup.stackup.github.application.dto;
2+
3+
public record CandidateRepositoryResult(
4+
Long githubRepoId,
5+
String name,
6+
String fullName,
7+
String htmlUrl,
8+
String defaultBranch,
9+
boolean privateRepo,
10+
String description,
11+
boolean alreadyRegistered
12+
) {
13+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.stackup.stackup.github.application.dto;
2+
3+
import com.stackup.stackup.github.domain.GithubRepository;
4+
import com.stackup.stackup.github.domain.RepositoryStatus;
5+
import java.time.Instant;
6+
7+
public record GithubRepositoryResult(
8+
Long id,
9+
Long githubRepoId,
10+
String repoName,
11+
String repoFullName,
12+
String repoUrl,
13+
String defaultBranch,
14+
RepositoryStatus status,
15+
Instant lastSyncedAt,
16+
Instant createdAt,
17+
Instant updatedAt
18+
) {
19+
public static GithubRepositoryResult from(GithubRepository r) {
20+
return new GithubRepositoryResult(
21+
r.getId(), r.getGithubRepoId(), r.getRepoName(), r.getRepoFullName(),
22+
r.getRepoUrl(), r.getDefaultBranch(), r.getStatus(),
23+
r.getLastSyncedAt(), r.getCreatedAt(), r.getUpdatedAt()
24+
);
25+
}
26+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.stackup.stackup.github.application.dto;
2+
3+
public record RegisterRepositoryCommand(Long githubRepoId) {
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.stackup.stackup.github.application.event;
2+
3+
public record RepositoryRegisteredEvent(
4+
Long repositoryId,
5+
Long userId,
6+
String githubFullName,
7+
String defaultBranch,
8+
String encryptedGithubAccessToken
9+
) {
10+
}

backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,33 @@ public class GithubRepository extends BaseSoftDeleteEntity {
6464

6565
@Column(name = "last_synced_at")
6666
private Instant lastSyncedAt;
67+
68+
public static GithubRepository create(
69+
User user,
70+
Long githubRepoId,
71+
String repoName,
72+
String repoFullName,
73+
String repoUrl,
74+
String defaultBranch
75+
) {
76+
GithubRepository repo = new GithubRepository();
77+
repo.user = user;
78+
repo.githubRepoId = githubRepoId;
79+
repo.repoName = repoName;
80+
repo.repoFullName = repoFullName;
81+
repo.repoUrl = repoUrl;
82+
repo.defaultBranch = defaultBranch == null ? "main" : defaultBranch;
83+
repo.status = RepositoryStatus.PENDING;
84+
return repo;
85+
}
86+
87+
public void resurrect(String repoName, String repoFullName, String repoUrl, String defaultBranch) {
88+
this.deleted = false;
89+
this.repoName = repoName;
90+
this.repoFullName = repoFullName;
91+
this.repoUrl = repoUrl;
92+
this.defaultBranch = defaultBranch == null ? "main" : defaultBranch;
93+
this.status = RepositoryStatus.PENDING;
94+
this.lastSyncedAt = null;
95+
}
6796
}

0 commit comments

Comments
 (0)