Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/invitation #773

Draft
wants to merge 13 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/main/kotlin/apply/application/InvitationResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package apply.application

import java.time.LocalDateTime

data class InvitationResponse(
val id: Long,
val githubUsername: String,
val repositoryName: String,
val repositoryFullName: String,
val invitationDateTime: LocalDateTime,
val expired: Boolean,
)
42 changes: 42 additions & 0 deletions src/main/kotlin/apply/application/InvitationService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package apply.application

import apply.infra.github.GitHub
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.ZoneId

@Service
class InvitationService(
private val gitHub: GitHub,
) {
fun findAll(): List<InvitationResponse> {
return gitHub
.getInvitations()
.map {
InvitationResponse(
it.id,
it.repository.owner.login,
it.repository.name,
it.repository.fullName,
it.createdAt.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime(),
it.expired
)
}
}

fun acceptAll(keyword: String, deadlineDateTime: LocalDateTime) {
require(keyword.isNotBlank()) { "키워드는 빈 값일 수 없습니다." }
findAll()
.filter { it.invitationDateTime <= deadlineDateTime }
.filter { it.repositoryFullName.contains(keyword, ignoreCase = true) }
.forEach { accept(it.id) }
}

fun accept(invitationId: Long) {
gitHub.acceptInvitation(invitationId)
}

fun decline(invitationId: Long) {
gitHub.declineInvitation(invitationId)
}
}
110 changes: 110 additions & 0 deletions src/main/kotlin/apply/infra/github/GitHub.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package apply.infra.github

import apply.domain.judgment.AssignmentArchive
import apply.domain.judgment.Commit
import apply.domain.mission.SubmissionMethod
import apply.domain.mission.SubmissionMethod.PRIVATE_REPOSITORY
import apply.domain.mission.SubmissionMethod.PUBLIC_PULL_REQUEST
import mu.KotlinLogging
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneId

private val log = KotlinLogging.logger { }
private const val PAGE_SIZE: Int = 100
private val PULL_REQUEST_URL_PATTERN: Regex =
"https://github\\.com/(?<owner>.+)/(?<repo>.+)/pull/(?<pullNumber>\\d+)".toRegex()
private val REPOSITORY_URL_PATTERN: Regex = "https://github\\.com/(?<owner>.+)/(?<repo>.+)".toRegex()

@Component
class GitHub(
private val gitHubClient: GitHubClient,
) : AssignmentArchive {
override fun getLastCommit(submissionMethod: SubmissionMethod, url: String, endDateTime: LocalDateTime): Commit {
val commits = when (submissionMethod) {
PUBLIC_PULL_REQUEST -> getCommitsFromPullRequest(url)
PRIVATE_REPOSITORY -> getCommitsFromRepository(url, endDateTime)
}
log.debug { "commits: $commits" }
return Commit(commits.last(endDateTime).hash)
}

private fun getCommitsFromPullRequest(url: String): List<CommitResponse> {
val (owner, repo, pullNumber) = PULL_REQUEST_URL_PATTERN.extractParts(url)
return generateSequence(1) { page -> page + 1 }
.map { page -> gitHubClient.getCommitsFromPullRequest(owner, repo, pullNumber.toInt(), page, PAGE_SIZE) }
.takeUntil { it.size < PAGE_SIZE }
.flatten()
.toList()
}

private fun getCommitsFromRepository(url: String, endDateTime: LocalDateTime): List<CommitResponse> {
val (owner, repo) = REPOSITORY_URL_PATTERN.extractParts(url)
return runCatching { gitHubClient.getCommitsFromRepository(owner, repo) }
.getOrElse {
when (it) {
is IllegalArgumentException -> acceptInvitationAndFetchCommits(owner, repo, endDateTime)
else -> throw it
}
}
}

private fun acceptInvitationAndFetchCommits(
owner: String,
repo: String,
endDateTime: LocalDateTime,
): List<CommitResponse> {
val invitation = getInvitation(owner, repo, endDateTime)
gitHubClient.acceptInvitation(invitation.id)
return gitHubClient.getCommitsFromRepository(owner, repo)
}

private fun getInvitation(owner: String, repo: String, deadlineDateTime: LocalDateTime): InvitationResponse {
return getInvitations()
.filter { it.createdAt.withZoneSameInstant(ZoneId.systemDefault()) <= deadlineDateTime.atZone(ZoneId.systemDefault()) }
.firstOrNull { it.matchesRepository(owner, repo) }
?: throw NoSuchElementException("조건을 충족하는 초대가 존재하지 않습니다.")
}

private fun InvitationResponse.matchesRepository(owner: String, repo: String): Boolean {
return repository.fullName.equals("$owner/$repo", ignoreCase = true)
}

private fun Regex.extractParts(url: String): List<String> {
val result = find(url) ?: throw IllegalArgumentException("올바른 형식의 URL이어야 합니다.")
return result.destructured.toList()
}

private fun List<CommitResponse>.last(endDateTime: LocalDateTime): CommitResponse {
val zonedDateTime = endDateTime.atZone(ZoneId.systemDefault())
return filter { it.date <= zonedDateTime }
.maxByOrNull { it.date }
?: throw IllegalArgumentException("해당 커밋이 존재하지 않습니다. endDateTime: $endDateTime")
}

fun getInvitations(): List<InvitationResponse> {
return generateSequence(1) { page -> page + 1 }
.map { page -> gitHubClient.getInvitations(page, PAGE_SIZE) }
.takeUntil { it.size < PAGE_SIZE }
.flatten()
.toList()
.also { log.debug { "invitations: $it" } }
}

fun acceptInvitation(invitationId: Long) {
gitHubClient.acceptInvitation(invitationId)
}

fun declineInvitation(invitationId: Long) {
gitHubClient.declineInvitation(invitationId)
}

private fun <T> Sequence<T>.takeUntil(predicate: (T) -> Boolean): Sequence<T> {
return sequence {
for (element in this@takeUntil) {
yield(element)
if (predicate(element)) break
}
}
}
}
86 changes: 45 additions & 41 deletions src/main/kotlin/apply/infra/github/GitHubClient.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
package apply.infra.github

import apply.domain.judgment.AssignmentArchive
import apply.domain.judgment.Commit
import apply.domain.mission.SubmissionMethod
import apply.domain.mission.SubmissionMethod.PRIVATE_REPOSITORY
import apply.domain.mission.SubmissionMethod.PUBLIC_PULL_REQUEST
import mu.KotlinLogging
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.http.HttpHeaders.ACCEPT
Expand All @@ -18,22 +13,16 @@ import org.springframework.web.client.HttpClientErrorException.Unauthorized
import org.springframework.web.client.RestClientResponseException
import org.springframework.web.client.RestTemplate
import org.springframework.web.client.exchange
import java.time.LocalDateTime
import java.time.ZoneId

private val log = KotlinLogging.logger { }
private const val API_VERSION_HEADER: String = "X-GitHub-Api-Version"
private const val API_VERSION: String = "2022-11-28"
private const val PAGE_SIZE: Int = 100
private val PULL_REQUEST_URL_PATTERN: Regex =
"https://github\\.com/(?<owner>.+)/(?<repo>.+)/pull/(?<pullNumber>\\d+)".toRegex()
private val REPOSITORY_URL_PATTERN: Regex = "https://github\\.com/(?<owner>.+)/(?<repo>.+)".toRegex()

@Component
class GitHubClient(
private val gitHubProperties: GitHubProperties,
restTemplateBuilder: RestTemplateBuilder,
) : AssignmentArchive {
) {
private val restTemplate: RestTemplate = restTemplateBuilder
.defaultHeader(ACCEPT, APPLICATION_JSON_VALUE)
.defaultHeader(AUTHORIZATION, bearerToken(gitHubProperties.accessKey))
Expand All @@ -42,42 +31,29 @@ class GitHubClient(

private fun bearerToken(token: String): String = "Bearer $token".takeIf { token.isNotEmpty() } ?: ""

override fun getLastCommit(submissionMethod: SubmissionMethod, url: String, endDateTime: LocalDateTime): Commit {
val commits = when (submissionMethod) {
PUBLIC_PULL_REQUEST -> getCommitsFromPullRequest(url)
PRIVATE_REPOSITORY -> getCommitsFromRepository(url)
}
return Commit(commits.last(endDateTime).hash)
}

/**
* 조회 시 커밋 날짜를 기준으로 오름차순으로 제공한다.
* 커밋이 250개가 넘는 경우 별도 대응이 필요하다.
* @see [API](https://docs.github.com/en/rest/pulls/pulls#list-commits-on-a-pull-request)
*/
private fun getCommitsFromPullRequest(url: String): List<CommitResponse> {
val (owner, repo, pullNumber) = PULL_REQUEST_URL_PATTERN.extractParts(url)
return generateSequence(1) { page -> page + 1 }
.map { page -> getCommits("${gitHubProperties.uri}/repos/$owner/$repo/pulls/$pullNumber/commits?per_page=$PAGE_SIZE&page=$page") }
.takeWhile { it.isNotEmpty() }
.flatten()
.toList()
fun getCommitsFromPullRequest(
owner: String,
repo: String,
pullNumber: Int,
page: Int,
size: Int,
): List<CommitResponse> {
return getCommits("${gitHubProperties.uri}/repos/$owner/$repo/pulls/$pullNumber/commits?per_page=$size&page=$page")
}

/**
* 조회 시 커밋 날짜를 기준으로 내림차순으로 제공한다.
* @see [API](https://docs.github.com/en/rest/commits/commits#list-commits)
*/
private fun getCommitsFromRepository(url: String): List<CommitResponse> {
val (owner, repo) = REPOSITORY_URL_PATTERN.extractParts(url)
fun getCommitsFromRepository(owner: String, repo: String): List<CommitResponse> {
return getCommits("${gitHubProperties.uri}/repos/$owner/$repo/commits")
}

private fun Regex.extractParts(url: String): List<String> {
val result = find(url) ?: throw IllegalArgumentException("올바른 형식의 URL이어야 합니다.")
return result.destructured.toList()
}

private fun getCommits(url: String): List<CommitResponse> {
val request = RequestEntity.get(url).build()
return runCatching { restTemplate.exchange<List<CommitResponse>>(request) }
Expand All @@ -87,6 +63,41 @@ class GitHubClient(
?: emptyList()
}

/**
* @see [API](https://docs.github.com/en/rest/collaborators/invitations#list-repository-invitations-for-the-authenticated-user)
*/
fun getInvitations(page: Int, size: Int): List<InvitationResponse> {
val url = "${gitHubProperties.uri}/user/repository_invitations?per_page=$size&page=$page"
val request = RequestEntity.get(url).build()
return runCatching { restTemplate.exchange<List<InvitationResponse>>(request) }
.onFailure { handleException(it, url) }
.map { it.body }
.getOrThrow()
?: emptyList()
}

/**
* @see [API](https://docs.github.com/en/rest/collaborators/invitations#accept-a-repository-invitation)
*/
fun acceptInvitation(invitationId: Long) {
val url = "${gitHubProperties.uri}/user/repository_invitations/$invitationId"
val request = RequestEntity.patch(url).build()
runCatching { restTemplate.exchange<String>(request) }
.onFailure { handleException(it, url) }
.getOrThrow()
}

/**
* @see [API](https://docs.github.com/en/rest/collaborators/invitations#decline-a-repository-invitation)
*/
fun declineInvitation(invitationId: Long) {
val url = "${gitHubProperties.uri}/user/repository_invitations/$invitationId"
val request = RequestEntity.delete(url).build()
runCatching { restTemplate.exchange<String>(request) }
.onFailure { handleException(it, url) }
.getOrThrow()
}

private fun handleException(exception: Throwable, url: String) {
val response = (exception as? RestClientResponseException)?.responseBodyAsString ?: throw exception
log.error { "error response: $response, url: $url" }
Expand All @@ -97,11 +108,4 @@ class GitHubClient(
else -> throw RuntimeException("예기치 않은 예외가 발생했습니다.", exception)
}
}

private fun List<CommitResponse>.last(endDateTime: LocalDateTime): CommitResponse {
val zonedDateTime = endDateTime.atZone(ZoneId.systemDefault())
return filter { it.date <= zonedDateTime }
.maxByOrNull { it.date }
?: throw IllegalArgumentException("해당 커밋이 존재하지 않습니다. endDateTime: $endDateTime")
}
}
24 changes: 24 additions & 0 deletions src/main/kotlin/apply/infra/github/GitHubDtos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZoneId
import java.time.ZonedDateTime

Expand All @@ -21,3 +23,25 @@ private class CommitDeserializer : JsonDeserializer<CommitResponse>() {

@JsonDeserialize(using = CommitDeserializer::class)
data class CommitResponse(val hash: String, val date: ZonedDateTime)

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class InvitationResponse(
val id: Long,
val repository: RepositoryResponse,
val createdAt: ZonedDateTime,
val expired: Boolean,
)

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class RepositoryResponse(
val id: Long,
val name: String,
val fullName: String,
val owner: OwnerResponse,
val private: Boolean,
val fork: Boolean,
)

data class OwnerResponse(
val login: String,
)
6 changes: 4 additions & 2 deletions src/main/kotlin/apply/ui/admin/BaseLayout.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import apply.application.RecruitmentService
import apply.ui.admin.administrator.AdministratorsView
import apply.ui.admin.cheater.CheatersView
import apply.ui.admin.evaluation.EvaluationsView
import apply.ui.admin.invitation.InvitationsView
import apply.ui.admin.mail.MailsView
import apply.ui.admin.recruitment.RecruitmentsView
import apply.ui.admin.term.TermsView
Expand All @@ -21,7 +22,7 @@ import support.views.createTabs

@Theme(value = Lumo::class)
class BaseLayout(
private val recruitmentService: RecruitmentService
private val recruitmentService: RecruitmentService,
) : AppLayout() {
init {
primarySection = Section.DRAWER
Expand All @@ -32,7 +33,7 @@ class BaseLayout(
private fun createDrawer(): Component {
return VerticalLayout().apply {
setSizeFull()
element.style.set("overflow", "auto")
element.style["overflow"] = "auto"
themeList["dark"] = true
alignItems = FlexComponent.Alignment.CENTER
add(createTitle(), createLogo(width), createMenu())
Expand Down Expand Up @@ -63,6 +64,7 @@ class BaseLayout(
"선발 과정".accordionOf("admin/selections", recruitments),
"부정행위자" of CheatersView::class.java,
"메일 관리" of MailsView::class.java,
"초대 관리" of InvitationsView::class.java,
"관리자" of AdministratorsView::class.java
)
}
Expand Down
Loading