Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.stackup.stackup.common.retry;

import java.util.Arrays;

public record RetryPolicy(long[] backoffMillis) {

public RetryPolicy {
if (backoffMillis == null) {
throw new IllegalArgumentException("backoffMillis must not be null");
}
for (long delay : backoffMillis) {
if (delay < 0) {
throw new IllegalArgumentException("backoffMillis must be non-negative, got: " + delay);
}
}
backoffMillis = backoffMillis.clone();
}

public static RetryPolicy of(long... backoffMillis) {
return new RetryPolicy(backoffMillis);
}

public static RetryPolicy exponentialBackoff(long initialDelayMillis, int maxAttempts) {
if (initialDelayMillis < 0) {
throw new IllegalArgumentException("initialDelayMillis must be non-negative, got: " + initialDelayMillis);
}
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be at least 1, got: " + maxAttempts);
}
if (maxAttempts > 50) {
throw new IllegalArgumentException("maxAttempts must be at most 50 to avoid overflow, got: " + maxAttempts);
}
long[] backoffMillis = new long[maxAttempts - 1];
for (int i = 0; i < backoffMillis.length; i++) {
backoffMillis[i] = initialDelayMillis * (1L << i);
}
return new RetryPolicy(backoffMillis);
}

public int maxAttempts() {
return backoffMillis.length + 1;
}

public long backoffForAttempt(int attempt) {
return backoffMillis[attempt - 1];
}

@Override
public long[] backoffMillis() {
return backoffMillis.clone();
}

@Override
public boolean equals(Object other) {
return other instanceof RetryPolicy that && Arrays.equals(backoffMillis, that.backoffMillis);
}

@Override
public int hashCode() {
return Arrays.hashCode(backoffMillis);
}

@Override
public String toString() {
return "RetryPolicy" + Arrays.toString(backoffMillis);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.stackup.stackup.common.retry;

import java.util.function.Supplier;

import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
public class RetryingExecutor {

private static final Logger log = LoggerFactory.getLogger(RetryingExecutor.class);

public void execute(String operationName, RetryPolicy policy, Runnable action) {
execute(operationName, policy, () -> {
action.run();
return null;
});
}

public <T> T execute(String operationName, RetryPolicy policy, Supplier<T> action) {
try {
return runWithRetry(operationName, policy, action);
} catch (RetryInterruptedException interrupted) {
log.error("Interrupted while retrying operation={}", operationName, interrupted);
return null;
} catch (RuntimeException terminalFailure) {
log.error("Exhausted retries for operation={} (attempts={})",
operationName, policy.maxAttempts(), terminalFailure);
return null;
}
}

public <T> T executeOrThrow(String operationName, RetryPolicy policy, Supplier<T> action) {
return runWithRetry(operationName, policy, action);
}

private <T> T runWithRetry(String operationName, @NonNull RetryPolicy policy, Supplier<T> action) {
int maxAttempts = policy.maxAttempts();
RuntimeException lastFailure = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
T result = action.get();
if (attempt > 1) {
log.info("Succeeded operation={} on retry attempt={}", operationName, attempt);
}
return result;
} catch (RuntimeException failure) {
lastFailure = failure;
if (attempt == maxAttempts) {
break;
}
long backoff = policy.backoffForAttempt(attempt);
log.warn("Operation failed name={} attempt={}/{}; retrying in {}ms",
operationName, attempt, maxAttempts, backoff, failure);
if (!sleep(backoff)) {
throw new RetryInterruptedException(operationName, failure);
}
}
}
throw lastFailure;
}

// millis 만큼 sleep합니다
// 테스트 코드에서 이 메서드를 오버라이드하여 sleep을 건너뛸 수 있습니다
protected boolean sleep(long millis) {
if (millis <= 0) {
return true;
}
try {
Thread.sleep(millis);
return true;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return false;
}
}

public static final class RetryInterruptedException extends RuntimeException {
public RetryInterruptedException(String operationName, Throwable cause) {
super("Interrupted while retrying operation=" + operationName, cause);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.stackup.stackup.github.application;

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.github.application.dto.CandidateRepositoryListResult;
import com.stackup.stackup.github.application.dto.CandidateRepositoryResult;
import com.stackup.stackup.github.application.dto.GithubRepositoryResult;
import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand;
import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent;
import com.stackup.stackup.github.domain.GithubRepository;
import com.stackup.stackup.github.domain.GithubRepositoryRepository;
import com.stackup.stackup.github.infrastructure.GithubApiClient;
import com.stackup.stackup.github.infrastructure.GithubTokenCipher;
import com.stackup.stackup.github.infrastructure.dto.GithubRepoListPage;
import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse;
import com.stackup.stackup.user.domain.User;
import com.stackup.stackup.user.domain.UserRepository;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class GithubRepositoryService {

private final GithubRepositoryRepository repoRepository;
private final UserRepository userRepository;
private final GithubApiClient githubApiClient;
private final GithubTokenCipher tokenCipher;
private final ApplicationEventPublisher events;


public Page<GithubRepositoryResult> list(Long userId, Pageable pageable) {
return repoRepository.findByUser_IdAndDeletedFalse(userId, pageable)
.map(GithubRepositoryResult::from);
}

public GithubRepositoryResult get(Long userId, Long repositoryId) {
return repoRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId)
.map(GithubRepositoryResult::from)
.orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND));
}

@Transactional
public void delete(Long userId, Long repositoryId) {
GithubRepository repo = repoRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND));
repo.softDelete();
}

@Transactional
public GithubRepositoryResult register(Long userId, RegisterRepositoryCommand command) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND));

String plainToken = tokenCipher.decrypt(user.getEncryptedGithubAccessToken());
GithubRepoResponse meta = githubApiClient.getRepository(plainToken, command.githubRepoId());

GithubRepository repo = repoRepository.findByUser_IdAndGithubRepoId(userId, command.githubRepoId())
.map(existing -> {
if (!existing.isDeleted()) {
throw new DomainException(ApiErrorCode.REPO_ALREADY_REGISTERED);
}
existing.resurrect(meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch());
return existing;
})
.orElseGet(() -> repoRepository.save(GithubRepository.create(
user,
meta.id(), meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch()
)));

String sealedToken = tokenCipher.encrypt(plainToken);
events.publishEvent(new RepositoryRegisteredEvent(
repo.getId(), userId, repo.getRepoFullName(), repo.getDefaultBranch(), sealedToken
));

return GithubRepositoryResult.from(repo);
}

public CandidateRepositoryListResult listCandidates(Long userId, int page, int perPage) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND));

String token = tokenCipher.decrypt(user.getEncryptedGithubAccessToken());
GithubRepoListPage listPage = githubApiClient.listUserRepositories(token, page, perPage);

Set<Long> githubIds = listPage.repos().stream()
.map(GithubRepoResponse::id)
.collect(Collectors.toSet());

Set<Long> registered = githubIds.isEmpty()
? Set.of()
: new HashSet<>(repoRepository
.findGithubRepoIdsByUser_IdAndDeletedFalseAndGithubRepoIdIn(userId, githubIds));

List<CandidateRepositoryResult> content = listPage.repos().stream()
.map(r -> new CandidateRepositoryResult(
r.id(), r.name(), r.fullName(), r.htmlUrl(), r.defaultBranch(),
r.privateRepo(), r.description(), registered.contains(r.id())
))
.toList();

return new CandidateRepositoryListResult(content, page, perPage, listPage.hasNext());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.stackup.stackup.github.application.dto;

import java.util.List;

public record CandidateRepositoryListResult(
List<CandidateRepositoryResult> content,
int page,
int perPage,
boolean hasNext
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.stackup.stackup.github.application.dto;

public record CandidateRepositoryResult(
Long githubRepoId,
String name,
String fullName,
String htmlUrl,
String defaultBranch,
boolean privateRepo,
String description,
boolean alreadyRegistered
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.stackup.stackup.github.application.dto;

import com.stackup.stackup.github.domain.GithubRepository;
import com.stackup.stackup.github.domain.RepositoryStatus;
import java.time.Instant;

public record GithubRepositoryResult(
Long id,
Long githubRepoId,
String repoName,
String repoFullName,
String repoUrl,
String defaultBranch,
RepositoryStatus status,
Instant lastSyncedAt,
Instant createdAt,
Instant updatedAt
) {
public static GithubRepositoryResult from(GithubRepository r) {
return new GithubRepositoryResult(
r.getId(), r.getGithubRepoId(), r.getRepoName(), r.getRepoFullName(),
r.getRepoUrl(), r.getDefaultBranch(), r.getStatus(),
r.getLastSyncedAt(), r.getCreatedAt(), r.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.stackup.stackup.github.application.dto;

public record RegisterRepositoryCommand(Long githubRepoId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stackup.stackup.github.application.event;

public record RepositoryRegisteredEvent(
Long repositoryId,
Long userId,
String githubFullName,
String defaultBranch,
String encryptedGithubAccessToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,33 @@ public class GithubRepository extends BaseSoftDeleteEntity {

@Column(name = "last_synced_at")
private Instant lastSyncedAt;

public static GithubRepository create(
User user,
Long githubRepoId,
String repoName,
String repoFullName,
String repoUrl,
String defaultBranch
) {
GithubRepository repo = new GithubRepository();
repo.user = user;
repo.githubRepoId = githubRepoId;
repo.repoName = repoName;
repo.repoFullName = repoFullName;
repo.repoUrl = repoUrl;
repo.defaultBranch = defaultBranch == null ? "main" : defaultBranch;
repo.status = RepositoryStatus.PENDING;
return repo;
}

public void resurrect(String repoName, String repoFullName, String repoUrl, String defaultBranch) {
this.deleted = false;
this.repoName = repoName;
this.repoFullName = repoFullName;
this.repoUrl = repoUrl;
this.defaultBranch = defaultBranch == null ? "main" : defaultBranch;
this.status = RepositoryStatus.PENDING;
this.lastSyncedAt = null;
}
}
Loading