From 9af37ed70537012712d7fbf82dea534e2bcb2d7e Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Thu, 24 Oct 2024 15:30:19 +0900 Subject: [PATCH 01/13] feat(judgment): implement to retrieve and accept invitations --- .../kotlin/apply/infra/github/GitHubClient.kt | 27 +++++++++++++++++++ .../kotlin/apply/infra/github/GitHubDtos.kt | 24 +++++++++++++++++ .../resources/application-test.properties | 2 +- .../apply/infra/github/GitHubClientTest.kt | 12 +++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/apply/infra/github/GitHubClient.kt b/src/main/kotlin/apply/infra/github/GitHubClient.kt index 752dd2de2..0098801bf 100644 --- a/src/main/kotlin/apply/infra/github/GitHubClient.kt +++ b/src/main/kotlin/apply/infra/github/GitHubClient.kt @@ -47,6 +47,7 @@ class GitHubClient( PUBLIC_PULL_REQUEST -> getCommitsFromPullRequest(url) PRIVATE_REPOSITORY -> getCommitsFromRepository(url) } + log.debug { "commits: $commits" } return Commit(commits.last(endDateTime).hash) } @@ -104,4 +105,30 @@ class GitHubClient( .maxByOrNull { it.date } ?: throw IllegalArgumentException("해당 커밋이 존재하지 않습니다. endDateTime: $endDateTime") } + + fun getInvitations(): List { + return generateSequence(1) { page -> page + 1 } + .map { page -> getInvitations("${gitHubProperties.uri}/user/repository_invitations?per_page=$PAGE_SIZE&page=$page") } + .takeWhile { it.isNotEmpty() } + .flatten() + .toList() + .also { log.debug { "invitations: $it" } } + } + + private fun getInvitations(url: String): List { + val request = RequestEntity.get(url).build() + return runCatching { restTemplate.exchange>(request) } + .onFailure { handleException(it, url) } + .map { it.body } + .getOrThrow() + ?: emptyList() + } + + fun acceptInvitation(invitationId: Long) { + val url = "${gitHubProperties.uri}/user/repository_invitations/$invitationId" + val request = RequestEntity.patch(url).build() + runCatching { restTemplate.exchange(request) } + .onFailure { handleException(it, url) } + .getOrThrow() + } } diff --git a/src/main/kotlin/apply/infra/github/GitHubDtos.kt b/src/main/kotlin/apply/infra/github/GitHubDtos.kt index c9918841e..6fadd2bc0 100644 --- a/src/main/kotlin/apply/infra/github/GitHubDtos.kt +++ b/src/main/kotlin/apply/infra/github/GitHubDtos.kt @@ -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 @@ -21,3 +23,25 @@ private class CommitDeserializer : JsonDeserializer() { @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, +) diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 82e1d43d1..4439f8771 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -7,7 +7,7 @@ spring.jpa.show-sql=true spring.flyway.enabled=false -logging.level.apply.application.mail=DEBUG +logging.level.apply=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE logging.level.org.springframework.web.client.RestTemplate=DEBUG diff --git a/src/test/kotlin/apply/infra/github/GitHubClientTest.kt b/src/test/kotlin/apply/infra/github/GitHubClientTest.kt index 0a5d8f759..fc05a21e6 100644 --- a/src/test/kotlin/apply/infra/github/GitHubClientTest.kt +++ b/src/test/kotlin/apply/infra/github/GitHubClientTest.kt @@ -6,6 +6,7 @@ import apply.domain.mission.SubmissionMethod.PRIVATE_REPOSITORY import apply.domain.mission.SubmissionMethod.PUBLIC_PULL_REQUEST import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import org.springframework.boot.web.client.RestTemplateBuilder import support.createLocalDateTime @@ -72,6 +73,17 @@ class GitHubClientTest( } } + "저장소 초대 목록을 조회한다" { + val actual = gitHubClient.getInvitations() + actual shouldHaveSize 0 + } + + "존재하지 않는 초대 ID를 수락하면 예외가 발생한다" { + shouldThrow { + gitHubClient.acceptInvitation(0L) + } + } + "비공개 저장소의 마지막 커밋을 조회한다".config(enabled = false) { val actual = gitHubClient.getLastCommit(PRIVATE_REPOSITORY, "https://github.com/applicant01/all-fail", now) actual shouldBe createCommit("936a0afb8da904ed9dfdea405042860395600047") From 95f3e6f68565a717bb5c13176589318b33463f3a Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Fri, 25 Oct 2024 15:50:11 +0900 Subject: [PATCH 02/13] feat(invitation): implement the invitations view --- src/main/kotlin/apply/ui/admin/BaseLayout.kt | 6 +- .../ui/admin/invitation/InvitationsView.kt | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt diff --git a/src/main/kotlin/apply/ui/admin/BaseLayout.kt b/src/main/kotlin/apply/ui/admin/BaseLayout.kt index 4bb074b4b..6de93135d 100644 --- a/src/main/kotlin/apply/ui/admin/BaseLayout.kt +++ b/src/main/kotlin/apply/ui/admin/BaseLayout.kt @@ -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 @@ -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 @@ -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()) @@ -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 ) } diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt new file mode 100644 index 000000000..a50aae1f1 --- /dev/null +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -0,0 +1,82 @@ +package apply.ui.admin.invitation + +import apply.ui.admin.BaseLayout +import com.vaadin.flow.component.Component +import com.vaadin.flow.component.grid.Grid +import com.vaadin.flow.component.html.H1 +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.data.renderer.ComponentRenderer +import com.vaadin.flow.data.renderer.Renderer +import com.vaadin.flow.router.Route +import support.views.addSortableColumn +import support.views.addSortableDateTimeColumn +import support.views.createErrorSmallButton +import support.views.createPrimarySmallButton +import java.time.LocalDateTime + +@Route(value = "admin/invitations", layout = BaseLayout::class) +class InvitationsView : VerticalLayout() { + init { + add(createTitle(), createGrid()) + } + + private fun createTitle(): Component { + return HorizontalLayout(H1("초대 관리")).apply { + setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + } + } + + private fun createGrid(): Component { + val invitations = listOf( + InvitationResponse( + id = 1L, + githubUsername = "woowahan-pjs", + repositoryName = "nextstep_test", + repositoryFullName = "woowahan-pjs/nextstep_test", + invitationDateTime = LocalDateTime.now(), + expired = false + ), + ) + return Grid(10).apply { + addSortableColumn("GitHub 사용자 이름", InvitationResponse::githubUsername) + addSortableColumn("저장소 이름", InvitationResponse::repositoryName) + addSortableColumn("저장소 전체 이름", InvitationResponse::repositoryFullName) + addSortableDateTimeColumn("초대 일시", InvitationResponse::invitationDateTime) + addColumn(createButtonRenderer()).apply { isAutoWidth = true } + setItems(invitations) + } + } + + private fun createButtonRenderer(): Renderer { + return ComponentRenderer { it -> createButtons(it) } + } + + private fun createButtons(invitation: InvitationResponse): Component { + return HorizontalLayout( + createAcceptButton(invitation), + createDeclineButton(invitation), + ) + } + + private fun createAcceptButton(invitation: InvitationResponse): Component { + return createPrimarySmallButton("수락") { + } + } + + private fun createDeclineButton(invitation: InvitationResponse): Component { + return createErrorSmallButton("거절") { + } + } +} + +data class InvitationResponse( + val id: Long, + val githubUsername: String, + val repositoryName: String, + val repositoryFullName: String, + val invitationDateTime: LocalDateTime, + val expired: Boolean, +) From dd7f53e7f390df48f8e8af5fe1e1b2f806f8f093 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Fri, 25 Oct 2024 16:00:05 +0900 Subject: [PATCH 03/13] refactor(invitation): move the preview dialog to support --- .../kotlin/apply/ui/admin/PreviewDialog.kt | 53 ------------------- .../apply/ui/admin/mail/MailsFormView.kt | 4 +- .../ui/admin/mission/MissionsFormView.kt | 4 +- src/main/kotlin/support/views/Components.kt | 46 ++++++++++++++++ 4 files changed, 50 insertions(+), 57 deletions(-) delete mode 100644 src/main/kotlin/apply/ui/admin/PreviewDialog.kt diff --git a/src/main/kotlin/apply/ui/admin/PreviewDialog.kt b/src/main/kotlin/apply/ui/admin/PreviewDialog.kt deleted file mode 100644 index 8e9450579..000000000 --- a/src/main/kotlin/apply/ui/admin/PreviewDialog.kt +++ /dev/null @@ -1,53 +0,0 @@ -package apply.ui.admin - -import com.vaadin.flow.component.Component -import com.vaadin.flow.component.Html -import com.vaadin.flow.component.button.Button -import com.vaadin.flow.component.dialog.Dialog -import com.vaadin.flow.component.html.H2 -import com.vaadin.flow.component.orderedlayout.FlexComponent -import com.vaadin.flow.component.orderedlayout.HorizontalLayout -import com.vaadin.flow.component.orderedlayout.VerticalLayout -import org.jsoup.Jsoup -import support.views.createContrastButton - -class PreviewDialog( - htmlText: String -) : Dialog() { - init { - add(createHeader(), createContent(htmlText), createButtons()) - width = "700px" - height = "800px" - open() - } - - private fun createHeader(): VerticalLayout { - return VerticalLayout(H2("미리 보기")).apply { - isPadding = false - element.style["margin-bottom"] = "10px" - } - } - - private fun createContent(htmlText: String): Component { - val body = Jsoup.parse(htmlText).body() - return Html(body.html()).apply { - element.style["display"] = "block" - element.style["height"] = "600px" - element.style["overflow"] = "auto" - } - } - - private fun createButtons(): Component { - return HorizontalLayout(createCloseButton()).apply { - setWidthFull() - justifyContentMode = FlexComponent.JustifyContentMode.CENTER - element.style["margin-top"] = "20px" - } - } - - private fun createCloseButton(): Button { - return createContrastButton("닫기") { - close() - } - } -} diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index 21a76d3b5..131e1091d 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -8,7 +8,6 @@ import apply.application.RecruitmentService import apply.application.mail.MailData import apply.application.mail.MailService import apply.ui.admin.BaseLayout -import apply.ui.admin.PreviewDialog import com.vaadin.flow.component.Component import com.vaadin.flow.component.UI import com.vaadin.flow.component.button.Button @@ -22,6 +21,7 @@ import com.vaadin.flow.router.WildcardParameter import org.springframework.boot.autoconfigure.mail.MailProperties import support.views.EDIT_VALUE import support.views.FORM_URL_PATTERN +import support.views.PreviewDialog import support.views.Title import support.views.createContrastButton import support.views.createNotification @@ -35,7 +35,7 @@ class MailsFormView( mailTargetService: MailTargetService, private val mailHistoryService: MailHistoryService, private val mailService: MailService, - mailProperties: MailProperties + mailProperties: MailProperties, ) : VerticalLayout(), HasUrlParameter { private val mailForm: MailForm = MailForm( memberService, diff --git a/src/main/kotlin/apply/ui/admin/mission/MissionsFormView.kt b/src/main/kotlin/apply/ui/admin/mission/MissionsFormView.kt index e1a42c072..629908f44 100644 --- a/src/main/kotlin/apply/ui/admin/mission/MissionsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mission/MissionsFormView.kt @@ -4,7 +4,6 @@ import apply.application.EvaluationService import apply.application.MissionData import apply.application.MissionService import apply.ui.admin.BaseLayout -import apply.ui.admin.PreviewDialog import com.vaadin.flow.component.Component import com.vaadin.flow.component.UI import com.vaadin.flow.component.button.Button @@ -17,6 +16,7 @@ import com.vaadin.flow.router.Route import com.vaadin.flow.router.WildcardParameter import support.views.EDIT_VALUE import support.views.NEW_VALUE +import support.views.PreviewDialog import support.views.Title import support.views.createContrastButton import support.views.createNotification @@ -28,7 +28,7 @@ private val MISSION_FORM_URL_PATTERN: Regex = Regex("^(\\d*)/?(\\d*)/?($NEW_VALU @Route(value = "admin/missions", layout = BaseLayout::class) class MissionsFormView( private val evaluationService: EvaluationService, - private val missionService: MissionService + private val missionService: MissionService, ) : VerticalLayout(), HasUrlParameter { private var recruitmentId: Long = 0L private val title: Title = Title() diff --git a/src/main/kotlin/support/views/Components.kt b/src/main/kotlin/support/views/Components.kt index 553af5e16..f92f0cbd0 100644 --- a/src/main/kotlin/support/views/Components.kt +++ b/src/main/kotlin/support/views/Components.kt @@ -2,21 +2,26 @@ package support.views import com.vaadin.flow.component.Component import com.vaadin.flow.component.HasText +import com.vaadin.flow.component.Html import com.vaadin.flow.component.Key import com.vaadin.flow.component.Text import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.dialog.Dialog import com.vaadin.flow.component.html.H1 +import com.vaadin.flow.component.html.H2 import com.vaadin.flow.component.icon.Icon import com.vaadin.flow.component.icon.VaadinIcon import com.vaadin.flow.component.notification.Notification import com.vaadin.flow.component.orderedlayout.FlexComponent import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout import com.vaadin.flow.component.radiobutton.RadioButtonGroup import com.vaadin.flow.component.select.Select import com.vaadin.flow.component.tabs.Tabs import com.vaadin.flow.component.tabs.TabsVariant import com.vaadin.flow.component.textfield.TextField import com.vaadin.flow.data.renderer.ComponentRenderer +import org.jsoup.Jsoup fun createIntSelect(min: Int = 0, max: Int): Select { return Select(*(min..max).toList().toTypedArray()) @@ -134,3 +139,44 @@ class Title(val value: H1) : HorizontalLayout(), HasText { return value.text } } + +class PreviewDialog( + htmlText: String, +) : Dialog() { + init { + add(createHeader(), createContent(htmlText), createButtons()) + width = "700px" + height = "800px" + open() + } + + private fun createHeader(): VerticalLayout { + return VerticalLayout(H2("미리 보기")).apply { + isPadding = false + element.style["margin-bottom"] = "10px" + } + } + + private fun createContent(htmlText: String): Component { + val body = Jsoup.parse(htmlText).body() + return Html(body.html()).apply { + element.style["display"] = "block" + element.style["height"] = "600px" + element.style["overflow"] = "auto" + } + } + + private fun createButtons(): Component { + return HorizontalLayout(createCloseButton()).apply { + setWidthFull() + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + element.style["margin-top"] = "20px" + } + } + + private fun createCloseButton(): Button { + return createContrastButton("닫기") { + close() + } + } +} From 307c2092dd173b3a9e64f0ccdba46115608bec62 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Fri, 25 Oct 2024 16:15:21 +0900 Subject: [PATCH 04/13] feat(invitation): implement the invitation service --- .../apply/application/InvitationResponse.kt | 12 +++++++ .../apply/application/InvitationService.kt | 32 +++++++++++++++++++ .../ui/admin/invitation/InvitationsView.kt | 30 +++++------------ 3 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 src/main/kotlin/apply/application/InvitationResponse.kt create mode 100644 src/main/kotlin/apply/application/InvitationService.kt diff --git a/src/main/kotlin/apply/application/InvitationResponse.kt b/src/main/kotlin/apply/application/InvitationResponse.kt new file mode 100644 index 000000000..58166665e --- /dev/null +++ b/src/main/kotlin/apply/application/InvitationResponse.kt @@ -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, +) diff --git a/src/main/kotlin/apply/application/InvitationService.kt b/src/main/kotlin/apply/application/InvitationService.kt new file mode 100644 index 000000000..0c457ca9b --- /dev/null +++ b/src/main/kotlin/apply/application/InvitationService.kt @@ -0,0 +1,32 @@ +package apply.application + +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class InvitationService { + fun findAll(): List { + return listOf( + InvitationResponse( + id = 1L, + githubUsername = "woowahan-pjs", + repositoryName = "nextstep_test", + repositoryFullName = "woowahan-pjs/nextstep_test", + invitationDateTime = LocalDateTime.now(), + expired = false + ), + ) + } + + fun acceptAll(keyword: String, deadlineDateTime: LocalDateTime) { + throw UnsupportedOperationException() + } + + fun accept(invitationId: Long) { + throw UnsupportedOperationException() + } + + fun decline(invitationId: Long) { + throw UnsupportedOperationException() + } +} diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index a50aae1f1..3ec4a6a49 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -1,5 +1,7 @@ package apply.ui.admin.invitation +import apply.application.InvitationResponse +import apply.application.InvitationService import apply.ui.admin.BaseLayout import com.vaadin.flow.component.Component import com.vaadin.flow.component.grid.Grid @@ -14,10 +16,11 @@ import support.views.addSortableColumn import support.views.addSortableDateTimeColumn import support.views.createErrorSmallButton import support.views.createPrimarySmallButton -import java.time.LocalDateTime @Route(value = "admin/invitations", layout = BaseLayout::class) -class InvitationsView : VerticalLayout() { +class InvitationsView( + private val invitationService: InvitationService, +) : VerticalLayout() { init { add(createTitle(), createGrid()) } @@ -30,23 +33,13 @@ class InvitationsView : VerticalLayout() { } private fun createGrid(): Component { - val invitations = listOf( - InvitationResponse( - id = 1L, - githubUsername = "woowahan-pjs", - repositoryName = "nextstep_test", - repositoryFullName = "woowahan-pjs/nextstep_test", - invitationDateTime = LocalDateTime.now(), - expired = false - ), - ) return Grid(10).apply { addSortableColumn("GitHub 사용자 이름", InvitationResponse::githubUsername) addSortableColumn("저장소 이름", InvitationResponse::repositoryName) addSortableColumn("저장소 전체 이름", InvitationResponse::repositoryFullName) addSortableDateTimeColumn("초대 일시", InvitationResponse::invitationDateTime) addColumn(createButtonRenderer()).apply { isAutoWidth = true } - setItems(invitations) + setItems(invitationService.findAll()) } } @@ -63,20 +56,13 @@ class InvitationsView : VerticalLayout() { private fun createAcceptButton(invitation: InvitationResponse): Component { return createPrimarySmallButton("수락") { + invitationService.accept(invitation.id) } } private fun createDeclineButton(invitation: InvitationResponse): Component { return createErrorSmallButton("거절") { + invitationService.decline(invitation.id) } } } - -data class InvitationResponse( - val id: Long, - val githubUsername: String, - val repositoryName: String, - val repositoryFullName: String, - val invitationDateTime: LocalDateTime, - val expired: Boolean, -) From 72c22c265f9bf7b5f5c514eb3da26c0a38f76f87 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Thu, 31 Oct 2024 19:20:03 +0900 Subject: [PATCH 05/13] feat(invitation): implement the invite acceptance dialog --- .../ui/admin/invitation/InvitationsView.kt | 15 ++++- .../invitation/InviteAcceptanceDialog.kt | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index 3ec4a6a49..053dfde96 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -4,6 +4,7 @@ import apply.application.InvitationResponse import apply.application.InvitationService import apply.ui.admin.BaseLayout import com.vaadin.flow.component.Component +import com.vaadin.flow.component.UI import com.vaadin.flow.component.grid.Grid import com.vaadin.flow.component.html.H1 import com.vaadin.flow.component.orderedlayout.FlexComponent @@ -15,6 +16,7 @@ import com.vaadin.flow.router.Route import support.views.addSortableColumn import support.views.addSortableDateTimeColumn import support.views.createErrorSmallButton +import support.views.createPrimaryButton import support.views.createPrimarySmallButton @Route(value = "admin/invitations", layout = BaseLayout::class) @@ -22,7 +24,7 @@ class InvitationsView( private val invitationService: InvitationService, ) : VerticalLayout() { init { - add(createTitle(), createGrid()) + add(createTitle(), createAcceptAllButton(), createGrid()) } private fun createTitle(): Component { @@ -32,6 +34,17 @@ class InvitationsView( } } + private fun createAcceptAllButton(): Component { + return HorizontalLayout( + createPrimaryButton("조건부 수락") { + InviteAcceptanceDialog(invitationService).open() + } + ).apply { + setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.END + } + } + private fun createGrid(): Component { return Grid(10).apply { addSortableColumn("GitHub 사용자 이름", InvitationResponse::githubUsername) diff --git a/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt b/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt new file mode 100644 index 000000000..9b8541301 --- /dev/null +++ b/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt @@ -0,0 +1,61 @@ +package apply.ui.admin.invitation + +import apply.application.InvitationService +import com.vaadin.flow.component.Component +import com.vaadin.flow.component.UI +import com.vaadin.flow.component.datetimepicker.DateTimePicker +import com.vaadin.flow.component.dialog.Dialog +import com.vaadin.flow.component.formlayout.FormLayout +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.textfield.TextField +import support.views.createContrastButton +import support.views.createPrimaryButton +import java.time.LocalDateTime + +class InviteAcceptanceDialog( + private val invitationService: InvitationService, + private val reloadComponents: () -> Unit = { UI.getCurrent().page.reload() }, +) : Dialog() { + private val keyword: TextField = TextField("키워드").apply { + placeholder = "저장소 이름에 포함된 단어" + } + private val deadlineDateTime: DateTimePicker = DateTimePicker("마감 일시").apply { value = LocalDateTime.now() } + + init { + add(createHeader(), createForm(), createButtons()) + } + + private fun createHeader(): Component { + return H2("조건부 수락").apply { + style["text-align"] = "center" + } + } + + private fun createForm(): Component { + return FormLayout(keyword, deadlineDateTime) + } + + private fun createButtons(): Component { + return HorizontalLayout(createCancelButton(), createAcceptButton()).apply { + style["flex-wrap"] = "wrap" + style["margin-top"] = "20px" + justifyContentMode = FlexComponent.JustifyContentMode.END + } + } + + private fun createCancelButton(): Component { + return createContrastButton("취소") { + close() + } + } + + private fun createAcceptButton(): Component { + return createPrimaryButton("수락") { + invitationService.acceptAll(keyword.value, deadlineDateTime.value) + reloadComponents() + close() + } + } +} From 06b689e845af3805eb2a9989626db5df63a33f0c Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 4 Nov 2024 13:15:54 +0900 Subject: [PATCH 06/13] feat(invitation): accept the invitation and access again if accessing the repository fails --- src/main/kotlin/apply/infra/github/GitHub.kt | 91 +++++++++++++++++++ .../kotlin/apply/infra/github/GitHubClient.kt | 86 +++++------------- .../apply/infra/github/GitHubClientTest.kt | 29 +++--- .../kotlin/apply/infra/github/GitHubTest.kt | 64 +++++++++++++ 4 files changed, 193 insertions(+), 77 deletions(-) create mode 100644 src/main/kotlin/apply/infra/github/GitHub.kt create mode 100644 src/test/kotlin/apply/infra/github/GitHubTest.kt diff --git a/src/main/kotlin/apply/infra/github/GitHub.kt b/src/main/kotlin/apply/infra/github/GitHub.kt new file mode 100644 index 000000000..97f8613bb --- /dev/null +++ b/src/main/kotlin/apply/infra/github/GitHub.kt @@ -0,0 +1,91 @@ +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/(?.+)/(?.+)/pull/(?\\d+)".toRegex() +private val REPOSITORY_URL_PATTERN: Regex = "https://github\\.com/(?.+)/(?.+)".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) + } + log.debug { "commits: $commits" } + return Commit(commits.last(endDateTime).hash) + } + + private fun getCommitsFromPullRequest(url: String): List { + 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): List { + val (owner, repo) = REPOSITORY_URL_PATTERN.extractParts(url) + return runCatching { gitHubClient.getCommitsFromRepository(owner, repo) } + .getOrElse { + when (it) { + is IllegalArgumentException -> acceptInvitationAndFetchCommits(owner, repo) + else -> throw it + } + } + } + + private fun acceptInvitationAndFetchCommits(owner: String, repo: String): List { + val invitation = getInvitations().first { it.repository.fullName.equals("$owner/$repo", false) } + gitHubClient.acceptInvitation(invitation.id) + return gitHubClient.getCommitsFromRepository(owner, repo) + } + + private fun Regex.extractParts(url: String): List { + val result = find(url) ?: throw IllegalArgumentException("올바른 형식의 URL이어야 합니다.") + return result.destructured.toList() + } + + private fun List.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 { + 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) + } + + private fun Sequence.takeUntil(predicate: (T) -> Boolean): Sequence { + return sequence { + for (element in this@takeUntil) { + yield(element) + if (predicate(element)) break + } + } + } +} diff --git a/src/main/kotlin/apply/infra/github/GitHubClient.kt b/src/main/kotlin/apply/infra/github/GitHubClient.kt index 0098801bf..b231eb56a 100644 --- a/src/main/kotlin/apply/infra/github/GitHubClient.kt +++ b/src/main/kotlin/apply/infra/github/GitHubClient.kt @@ -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 @@ -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/(?.+)/(?.+)/pull/(?\\d+)".toRegex() -private val REPOSITORY_URL_PATTERN: Regex = "https://github\\.com/(?.+)/(?.+)".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)) @@ -42,43 +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) - } - log.debug { "commits: $commits" } - 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 { - 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 { + 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 { - val (owner, repo) = REPOSITORY_URL_PATTERN.extractParts(url) + fun getCommitsFromRepository(owner: String, repo: String): List { return getCommits("${gitHubProperties.uri}/repos/$owner/$repo/commits") } - private fun Regex.extractParts(url: String): List { - val result = find(url) ?: throw IllegalArgumentException("올바른 형식의 URL이어야 합니다.") - return result.destructured.toList() - } - private fun getCommits(url: String): List { val request = RequestEntity.get(url).build() return runCatching { restTemplate.exchange>(request) } @@ -88,34 +63,8 @@ class GitHubClient( ?: emptyList() } - private fun handleException(exception: Throwable, url: String) { - val response = (exception as? RestClientResponseException)?.responseBodyAsString ?: throw exception - log.error { "error response: $response, url: $url" } - when (exception) { - is Unauthorized -> throw IllegalStateException("유효한 토큰이 아닙니다.") - is Forbidden -> throw IllegalStateException("요청 한도에 도달했습니다.") - is NotFound -> throw IllegalArgumentException("리소스가 존재하지 않거나 접근할 수 없습니다.") - else -> throw RuntimeException("예기치 않은 예외가 발생했습니다.", exception) - } - } - - private fun List.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 { - return generateSequence(1) { page -> page + 1 } - .map { page -> getInvitations("${gitHubProperties.uri}/user/repository_invitations?per_page=$PAGE_SIZE&page=$page") } - .takeWhile { it.isNotEmpty() } - .flatten() - .toList() - .also { log.debug { "invitations: $it" } } - } - - private fun getInvitations(url: String): List { + fun getInvitations(page: Int, size: Int): List { + val url = "${gitHubProperties.uri}/user/repository_invitations?per_page=$size&page=$page" val request = RequestEntity.get(url).build() return runCatching { restTemplate.exchange>(request) } .onFailure { handleException(it, url) } @@ -131,4 +80,15 @@ class GitHubClient( .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" } + when (exception) { + is Unauthorized -> throw IllegalStateException("유효한 토큰이 아닙니다.") + is Forbidden -> throw IllegalStateException("요청 한도에 도달했습니다.") + is NotFound -> throw IllegalArgumentException("리소스가 존재하지 않거나 접근할 수 없습니다.") + else -> throw RuntimeException("예기치 않은 예외가 발생했습니다.", exception) + } + } } diff --git a/src/test/kotlin/apply/infra/github/GitHubClientTest.kt b/src/test/kotlin/apply/infra/github/GitHubClientTest.kt index fc05a21e6..a76319e81 100644 --- a/src/test/kotlin/apply/infra/github/GitHubClientTest.kt +++ b/src/test/kotlin/apply/infra/github/GitHubClientTest.kt @@ -18,37 +18,38 @@ class GitHubClientTest( private val gitHubClient: GitHubClient, private val properties: GitHubProperties, private val builder: RestTemplateBuilder, + private val gitHub: GitHub, ) : StringSpec({ val now = now() "설정된 날짜와 시간을 기준으로 마지막 커밋을 조회한다" { - val actual = gitHubClient.getLastCommit( + val actual = gitHub.getLastCommit( PUBLIC_PULL_REQUEST, PUBLIC_PULL_REQUEST_URL_VALUE, createLocalDateTime(2021, 10, 11) ) actual shouldBe createCommit("8c2d61313838d9220848bd38a5a5adc34efc5169") } "풀 리퀘스트의 마지막 커밋을 조회한다" { - val actual = gitHubClient.getLastCommit(PUBLIC_PULL_REQUEST, PUBLIC_PULL_REQUEST_URL_VALUE, now) + val actual = gitHub.getLastCommit(PUBLIC_PULL_REQUEST, PUBLIC_PULL_REQUEST_URL_VALUE, now) actual shouldBe createCommit("eeb43de3f53f4bec08e7d63f07badb66c12dfa31") } "커밋이 100개 이상인 풀 리퀘스트에서 마지막 커밋을 조회한다" { - val actual = gitHubClient.getLastCommit( + val actual = gitHub.getLastCommit( PUBLIC_PULL_REQUEST, "https://github.com/woowacourse/nextstep_test/pull/697", now ) actual shouldBe createCommit("8c4a97b43cf4db7f3d3f6ec53de0751eb20bdae0") } "저장소의 마지막 커밋을 조회한다" { - val actual = gitHubClient.getLastCommit( + val actual = gitHub.getLastCommit( PRIVATE_REPOSITORY, "https://github.com/woowacourse/java-chicken-2019", now ) actual shouldBe createCommit("e7d2311185e7a5f8dbee4e14231d27ece16ae343") } "커밋이 100개 이상인 저장소에서 마지막 커밋을 조회한다" { - val actual = gitHubClient.getLastCommit( + val actual = gitHub.getLastCommit( PRIVATE_REPOSITORY, "https://github.com/woowahan-pjs/nextstep_test", now ) actual shouldBe createCommit("8c4a97b43cf4db7f3d3f6ec53de0751eb20bdae0") @@ -57,35 +58,35 @@ class GitHubClientTest( "토큰이 유효하지 않으면 예외가 발생한다" { val client = GitHubClient(properties.copy(accessKey = "invalid_token"), builder) shouldThrow { - client.getLastCommit(PRIVATE_REPOSITORY, "https://github.com/woowacourse/java-chicken-2019", now) + client.getCommitsFromRepository(owner = "woowacourse", repo = "java-chicken-2019") } } "리소스가 없으면 예외가 발생한다" { shouldThrow { - gitHubClient.getLastCommit(PUBLIC_PULL_REQUEST, "https://github.com/woowacourse/service-apply/pull/1", now) + gitHub.getLastCommit(PUBLIC_PULL_REQUEST, "https://github.com/woowacourse/service-apply/pull/1", now) } } "해당 커밋이 없으면 예외가 발생한다" { shouldThrow { - gitHubClient.getLastCommit(PUBLIC_PULL_REQUEST, PUBLIC_PULL_REQUEST_URL_VALUE, createLocalDateTime(2018)) + gitHub.getLastCommit(PUBLIC_PULL_REQUEST, PUBLIC_PULL_REQUEST_URL_VALUE, createLocalDateTime(2018)) } } - "저장소 초대 목록을 조회한다" { - val actual = gitHubClient.getInvitations() - actual shouldHaveSize 0 - } - "존재하지 않는 초대 ID를 수락하면 예외가 발생한다" { shouldThrow { gitHubClient.acceptInvitation(0L) } } + "저장소 초대 목록을 조회한다".config(enabled = false) { + val actual = gitHubClient.getInvitations(1, 100) + actual shouldHaveSize 0 + } + "비공개 저장소의 마지막 커밋을 조회한다".config(enabled = false) { - val actual = gitHubClient.getLastCommit(PRIVATE_REPOSITORY, "https://github.com/applicant01/all-fail", now) + val actual = gitHub.getLastCommit(PRIVATE_REPOSITORY, "https://github.com/applicant01/all-fail", now) actual shouldBe createCommit("936a0afb8da904ed9dfdea405042860395600047") } }) diff --git a/src/test/kotlin/apply/infra/github/GitHubTest.kt b/src/test/kotlin/apply/infra/github/GitHubTest.kt new file mode 100644 index 000000000..7facb5a51 --- /dev/null +++ b/src/test/kotlin/apply/infra/github/GitHubTest.kt @@ -0,0 +1,64 @@ +package apply.infra.github + +import apply.domain.mission.SubmissionMethod.PRIVATE_REPOSITORY +import io.kotest.core.spec.style.BehaviorSpec +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +private val today: LocalDateTime = LocalDateTime.now() +private val yesterday: LocalDateTime = today.minusDays(1L) + +class GitHubTest : BehaviorSpec({ + val gitHubClient = mockk() + + val github = GitHub(gitHubClient) + + Given("비공개 저장소에 대한 접근 권한이 없고 리포지토리 초대가 있는 경우") { + val owner = "woowahan-pjs" + val repo = "nextstep_test" + + every { gitHubClient.getCommitsFromRepository(any(), any()) } + .throws(IllegalArgumentException()) + .andThen(listOf(CommitResponse("hash", yesterday.atZone(ZoneId.systemDefault())))) + every { gitHubClient.getInvitations(any(), any()) } returns listOf(createInvitationResponse(owner, repo)) + every { gitHubClient.acceptInvitation(any()) } just Runs + + When("마지막 커밋을 조회하면") { + github.getLastCommit(PRIVATE_REPOSITORY, "https://github.com/$owner/$repo", today) + + Then("해당 초대를 수락하고 커밋을 다시 조회한다") { + verify(exactly = 1) { gitHubClient.acceptInvitation(any()) } + verify(exactly = 2) { gitHubClient.getCommitsFromRepository(any(), any()) } + } + } + } + + // 초대 목록에 일치하는 저장소 이름이 없는 경우 + // 수락했지만 접근이 불가능한 경우 + // 수락 전 접근이 가능한 경우 +}) + +private fun createInvitationResponse( + owner: String, + repo: String, +): InvitationResponse { + return InvitationResponse( + 1L, + RepositoryResponse( + id = 1L, + name = repo, + fullName = "$owner/$repo", + owner = OwnerResponse(owner), + private = true, + fork = true + ), + ZonedDateTime.now(), + false + ) +} From 9f280c3225b5396fed51931d3fead086fbdf2dc9 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 4 Nov 2024 14:00:07 +0900 Subject: [PATCH 07/13] feat(invitation): connect to actual github --- .../apply/application/InvitationService.kt | 36 ++++++++++++------- src/main/kotlin/apply/infra/github/GitHub.kt | 2 +- .../kotlin/apply/infra/github/GitHubClient.kt | 6 ++++ .../invitation/InviteAcceptanceDialog.kt | 11 ++++-- .../resources/application-local.properties | 1 + 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/apply/application/InvitationService.kt b/src/main/kotlin/apply/application/InvitationService.kt index 0c457ca9b..06ab6270d 100644 --- a/src/main/kotlin/apply/application/InvitationService.kt +++ b/src/main/kotlin/apply/application/InvitationService.kt @@ -1,29 +1,39 @@ package apply.application +import apply.infra.github.GitHub import org.springframework.stereotype.Service import java.time.LocalDateTime +import java.time.ZoneId @Service -class InvitationService { +class InvitationService( + private val gitHub: GitHub, +) { fun findAll(): List { - return listOf( - InvitationResponse( - id = 1L, - githubUsername = "woowahan-pjs", - repositoryName = "nextstep_test", - repositoryFullName = "woowahan-pjs/nextstep_test", - invitationDateTime = LocalDateTime.now(), - expired = false - ), - ) + 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) { - throw UnsupportedOperationException() + require(keyword.isNotBlank()) { "키워드는 빈 값일 수 없습니다." } + findAll() + .filter { it.invitationDateTime <= deadlineDateTime } + .filter { it.repositoryFullName.contains(keyword, ignoreCase = true) } + .forEach { accept(it.id) } } fun accept(invitationId: Long) { - throw UnsupportedOperationException() + gitHub.acceptInvitation(invitationId) } fun decline(invitationId: Long) { diff --git a/src/main/kotlin/apply/infra/github/GitHub.kt b/src/main/kotlin/apply/infra/github/GitHub.kt index 97f8613bb..8b59c23c1 100644 --- a/src/main/kotlin/apply/infra/github/GitHub.kt +++ b/src/main/kotlin/apply/infra/github/GitHub.kt @@ -50,7 +50,7 @@ class GitHub( } private fun acceptInvitationAndFetchCommits(owner: String, repo: String): List { - val invitation = getInvitations().first { it.repository.fullName.equals("$owner/$repo", false) } + val invitation = getInvitations().first { it.repository.fullName.equals("$owner/$repo", ignoreCase = true) } gitHubClient.acceptInvitation(invitation.id) return gitHubClient.getCommitsFromRepository(owner, repo) } diff --git a/src/main/kotlin/apply/infra/github/GitHubClient.kt b/src/main/kotlin/apply/infra/github/GitHubClient.kt index b231eb56a..791d1082b 100644 --- a/src/main/kotlin/apply/infra/github/GitHubClient.kt +++ b/src/main/kotlin/apply/infra/github/GitHubClient.kt @@ -63,6 +63,9 @@ 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 { val url = "${gitHubProperties.uri}/user/repository_invitations?per_page=$size&page=$page" val request = RequestEntity.get(url).build() @@ -73,6 +76,9 @@ class GitHubClient( ?: 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() diff --git a/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt b/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt index 9b8541301..83c7884c3 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InviteAcceptanceDialog.kt @@ -11,6 +11,7 @@ import com.vaadin.flow.component.orderedlayout.FlexComponent import com.vaadin.flow.component.orderedlayout.HorizontalLayout import com.vaadin.flow.component.textfield.TextField import support.views.createContrastButton +import support.views.createNotification import support.views.createPrimaryButton import java.time.LocalDateTime @@ -53,9 +54,13 @@ class InviteAcceptanceDialog( private fun createAcceptButton(): Component { return createPrimaryButton("수락") { - invitationService.acceptAll(keyword.value, deadlineDateTime.value) - reloadComponents() - close() + try { + invitationService.acceptAll(keyword.value, deadlineDateTime.value) + reloadComponents() + close() + } catch (e: Exception) { + createNotification(e.localizedMessage) + } } } } diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 127afb83c..14f685091 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -8,6 +8,7 @@ spring.jpa.properties.hibernate.dialect.storage_engine=innodb spring.jpa.properties.hibernate.format_sql=true spring.jpa.show-sql=true +logging.level.apply=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE logging.level.org.springframework.web.client.RestTemplate=DEBUG From ffa6a26483dbbd9d2656b774a1b5ac877eee6f37 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 4 Nov 2024 14:55:12 +0900 Subject: [PATCH 08/13] feat(invitation): add the deadline for accepting invitations --- src/main/kotlin/apply/infra/github/GitHub.kt | 16 +++++++++++----- .../apply/application/JudgmentIntegrationTest.kt | 4 ++-- src/test/kotlin/apply/infra/github/GitHubTest.kt | 7 +++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/apply/infra/github/GitHub.kt b/src/main/kotlin/apply/infra/github/GitHub.kt index 8b59c23c1..2b6589abc 100644 --- a/src/main/kotlin/apply/infra/github/GitHub.kt +++ b/src/main/kotlin/apply/infra/github/GitHub.kt @@ -23,7 +23,7 @@ class GitHub( override fun getLastCommit(submissionMethod: SubmissionMethod, url: String, endDateTime: LocalDateTime): Commit { val commits = when (submissionMethod) { PUBLIC_PULL_REQUEST -> getCommitsFromPullRequest(url) - PRIVATE_REPOSITORY -> getCommitsFromRepository(url) + PRIVATE_REPOSITORY -> getCommitsFromRepository(url, endDateTime) } log.debug { "commits: $commits" } return Commit(commits.last(endDateTime).hash) @@ -38,19 +38,25 @@ class GitHub( .toList() } - private fun getCommitsFromRepository(url: String): List { + private fun getCommitsFromRepository(url: String, endDateTime: LocalDateTime): List { val (owner, repo) = REPOSITORY_URL_PATTERN.extractParts(url) return runCatching { gitHubClient.getCommitsFromRepository(owner, repo) } .getOrElse { when (it) { - is IllegalArgumentException -> acceptInvitationAndFetchCommits(owner, repo) + is IllegalArgumentException -> acceptInvitationAndFetchCommits(owner, repo, endDateTime) else -> throw it } } } - private fun acceptInvitationAndFetchCommits(owner: String, repo: String): List { - val invitation = getInvitations().first { it.repository.fullName.equals("$owner/$repo", ignoreCase = true) } + private fun acceptInvitationAndFetchCommits( + owner: String, + repo: String, + endDateTime: LocalDateTime, + ): List { + val invitation = getInvitations() + .filter { it.createdAt.withZoneSameInstant(ZoneId.systemDefault()) <= endDateTime.atZone(ZoneId.systemDefault()) } + .first { it.repository.fullName.equals("$owner/$repo", ignoreCase = true) } gitHubClient.acceptInvitation(invitation.id) return gitHubClient.getCommitsFromRepository(owner, repo) } diff --git a/src/test/kotlin/apply/application/JudgmentIntegrationTest.kt b/src/test/kotlin/apply/application/JudgmentIntegrationTest.kt index dd05d54d3..c3b5fbbaf 100644 --- a/src/test/kotlin/apply/application/JudgmentIntegrationTest.kt +++ b/src/test/kotlin/apply/application/JudgmentIntegrationTest.kt @@ -42,7 +42,7 @@ import support.test.context.event.RecordEventsConfiguration import support.test.spec.afterRootTest import java.time.LocalDateTime.now -@MockkBean(value = [AssignmentArchive::class, JudgmentAgency::class], relaxUnitFun = true) +@MockkBean(value = [AssignmentArchive::class, JudgmentAgency::class, InvitationService::class], relaxUnitFun = true) @Import(RecordEventsConfiguration::class) @IntegrationTest class JudgmentIntegrationTest( @@ -52,7 +52,7 @@ class JudgmentIntegrationTest( private val assignmentRepository: AssignmentRepository, private val judgmentRepository: JudgmentRepository, private val assignmentArchive: AssignmentArchive, - private val events: Events + private val events: Events, ) : BehaviorSpec({ Given("과제 제출물을 제출할 수 있는 특정 과제에 대한 과제 제출물이 있는 경우") { val memberId = 1L diff --git a/src/test/kotlin/apply/infra/github/GitHubTest.kt b/src/test/kotlin/apply/infra/github/GitHubTest.kt index 7facb5a51..84d0dab84 100644 --- a/src/test/kotlin/apply/infra/github/GitHubTest.kt +++ b/src/test/kotlin/apply/infra/github/GitHubTest.kt @@ -26,7 +26,9 @@ class GitHubTest : BehaviorSpec({ every { gitHubClient.getCommitsFromRepository(any(), any()) } .throws(IllegalArgumentException()) .andThen(listOf(CommitResponse("hash", yesterday.atZone(ZoneId.systemDefault())))) - every { gitHubClient.getInvitations(any(), any()) } returns listOf(createInvitationResponse(owner, repo)) + every { gitHubClient.getInvitations(any(), any()) } returns listOf( + createInvitationResponse(owner, repo, yesterday.atZone(ZoneId.systemDefault())) + ) every { gitHubClient.acceptInvitation(any()) } just Runs When("마지막 커밋을 조회하면") { @@ -47,6 +49,7 @@ class GitHubTest : BehaviorSpec({ private fun createInvitationResponse( owner: String, repo: String, + createdAt: ZonedDateTime, ): InvitationResponse { return InvitationResponse( 1L, @@ -58,7 +61,7 @@ private fun createInvitationResponse( private = true, fork = true ), - ZonedDateTime.now(), + createdAt, false ) } From 446eaea0c74c96aaec106b12beca6ddf53460156 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 4 Nov 2024 15:05:08 +0900 Subject: [PATCH 09/13] refactor(invitation): remove unused imports --- src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index 053dfde96..e43c263f9 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -4,7 +4,6 @@ import apply.application.InvitationResponse import apply.application.InvitationService import apply.ui.admin.BaseLayout import com.vaadin.flow.component.Component -import com.vaadin.flow.component.UI import com.vaadin.flow.component.grid.Grid import com.vaadin.flow.component.html.H1 import com.vaadin.flow.component.orderedlayout.FlexComponent From c154c49f1e3494ce078a2eb463f46d552747299f Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Tue, 5 Nov 2024 09:25:25 +0900 Subject: [PATCH 10/13] feat(invitation): improve usability --- src/main/kotlin/apply/infra/github/GitHub.kt | 15 ++++++++++++--- .../apply/ui/admin/invitation/InvitationsView.kt | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/apply/infra/github/GitHub.kt b/src/main/kotlin/apply/infra/github/GitHub.kt index 2b6589abc..de1fad0ad 100644 --- a/src/main/kotlin/apply/infra/github/GitHub.kt +++ b/src/main/kotlin/apply/infra/github/GitHub.kt @@ -54,13 +54,22 @@ class GitHub( repo: String, endDateTime: LocalDateTime, ): List { - val invitation = getInvitations() - .filter { it.createdAt.withZoneSameInstant(ZoneId.systemDefault()) <= endDateTime.atZone(ZoneId.systemDefault()) } - .first { it.repository.fullName.equals("$owner/$repo", ignoreCase = true) } + 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 { val result = find(url) ?: throw IllegalArgumentException("올바른 형식의 URL이어야 합니다.") return result.destructured.toList() diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index e43c263f9..cfb2f8610 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -4,6 +4,7 @@ import apply.application.InvitationResponse import apply.application.InvitationService import apply.ui.admin.BaseLayout import com.vaadin.flow.component.Component +import com.vaadin.flow.component.UI import com.vaadin.flow.component.grid.Grid import com.vaadin.flow.component.html.H1 import com.vaadin.flow.component.orderedlayout.FlexComponent @@ -69,6 +70,7 @@ class InvitationsView( private fun createAcceptButton(invitation: InvitationResponse): Component { return createPrimarySmallButton("수락") { invitationService.accept(invitation.id) + UI.getCurrent().page.reload() } } From 8ade4e5aa2d3627fa514f3703934b941900e4649 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Sat, 9 Nov 2024 13:35:12 +0900 Subject: [PATCH 11/13] feat(invitation): add the label for total number of invitations --- .../kotlin/apply/ui/admin/cheater/CheatersView.kt | 2 +- .../apply/ui/admin/invitation/InvitationsView.kt | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt b/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt index af9389e3e..d19c55a8f 100644 --- a/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt +++ b/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt @@ -44,8 +44,8 @@ class CheatersView( } } ).apply { - justifyContentMode = FlexComponent.JustifyContentMode.END setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.END } } diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index cfb2f8610..980acb5a7 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -7,6 +7,7 @@ import com.vaadin.flow.component.Component import com.vaadin.flow.component.UI import com.vaadin.flow.component.grid.Grid import com.vaadin.flow.component.html.H1 +import com.vaadin.flow.component.html.Label import com.vaadin.flow.component.orderedlayout.FlexComponent import com.vaadin.flow.component.orderedlayout.HorizontalLayout import com.vaadin.flow.component.orderedlayout.VerticalLayout @@ -24,12 +25,13 @@ class InvitationsView( private val invitationService: InvitationService, ) : VerticalLayout() { init { + setSizeFull() add(createTitle(), createAcceptAllButton(), createGrid()) } private fun createTitle(): Component { return HorizontalLayout(H1("초대 관리")).apply { - setSizeFull() + setWidthFull() justifyContentMode = FlexComponent.JustifyContentMode.CENTER } } @@ -40,7 +42,7 @@ class InvitationsView( InviteAcceptanceDialog(invitationService).open() } ).apply { - setSizeFull() + setWidthFull() justifyContentMode = FlexComponent.JustifyContentMode.END } } @@ -52,7 +54,10 @@ class InvitationsView( addSortableColumn("저장소 전체 이름", InvitationResponse::repositoryFullName) addSortableDateTimeColumn("초대 일시", InvitationResponse::invitationDateTime) addColumn(createButtonRenderer()).apply { isAutoWidth = true } - setItems(invitationService.findAll()) + invitationService.findAll().also { + setItems(it) + columns.first().setFooter("총 ${it.size}개") + } } } From ba52d3622980f9d7c8768f6224cc2dadf90f9c15 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Sat, 9 Nov 2024 15:10:16 +0900 Subject: [PATCH 12/13] feat(invitation): implement to decline invitations --- .../kotlin/apply/application/InvitationService.kt | 2 +- src/main/kotlin/apply/infra/github/GitHub.kt | 4 ++++ src/main/kotlin/apply/infra/github/GitHubClient.kt | 11 +++++++++++ .../apply/ui/admin/invitation/InvitationsView.kt | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/apply/application/InvitationService.kt b/src/main/kotlin/apply/application/InvitationService.kt index 06ab6270d..7f844c8a2 100644 --- a/src/main/kotlin/apply/application/InvitationService.kt +++ b/src/main/kotlin/apply/application/InvitationService.kt @@ -37,6 +37,6 @@ class InvitationService( } fun decline(invitationId: Long) { - throw UnsupportedOperationException() + gitHub.declineInvitation(invitationId) } } diff --git a/src/main/kotlin/apply/infra/github/GitHub.kt b/src/main/kotlin/apply/infra/github/GitHub.kt index de1fad0ad..828c2f548 100644 --- a/src/main/kotlin/apply/infra/github/GitHub.kt +++ b/src/main/kotlin/apply/infra/github/GitHub.kt @@ -95,6 +95,10 @@ class GitHub( gitHubClient.acceptInvitation(invitationId) } + fun declineInvitation(invitationId: Long) { + gitHubClient.declineInvitation(invitationId) + } + private fun Sequence.takeUntil(predicate: (T) -> Boolean): Sequence { return sequence { for (element in this@takeUntil) { diff --git a/src/main/kotlin/apply/infra/github/GitHubClient.kt b/src/main/kotlin/apply/infra/github/GitHubClient.kt index 791d1082b..b2095adc7 100644 --- a/src/main/kotlin/apply/infra/github/GitHubClient.kt +++ b/src/main/kotlin/apply/infra/github/GitHubClient.kt @@ -87,6 +87,17 @@ class GitHubClient( .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(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" } diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index 980acb5a7..d236f9112 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -82,6 +82,7 @@ class InvitationsView( private fun createDeclineButton(invitation: InvitationResponse): Component { return createErrorSmallButton("거절") { invitationService.decline(invitation.id) + UI.getCurrent().page.reload() } } } From 2c0ac519a06358d6f5d5b4820f0656afdbdd85ea Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Sat, 9 Nov 2024 15:40:08 +0900 Subject: [PATCH 13/13] feat(invitation): show the notification when invitations cannot be fetched --- .../apply/ui/admin/invitation/InvitationsView.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt index d236f9112..055c94b9f 100644 --- a/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt +++ b/src/main/kotlin/apply/ui/admin/invitation/InvitationsView.kt @@ -7,7 +7,6 @@ import com.vaadin.flow.component.Component import com.vaadin.flow.component.UI import com.vaadin.flow.component.grid.Grid import com.vaadin.flow.component.html.H1 -import com.vaadin.flow.component.html.Label import com.vaadin.flow.component.orderedlayout.FlexComponent import com.vaadin.flow.component.orderedlayout.HorizontalLayout import com.vaadin.flow.component.orderedlayout.VerticalLayout @@ -17,6 +16,7 @@ import com.vaadin.flow.router.Route import support.views.addSortableColumn import support.views.addSortableDateTimeColumn import support.views.createErrorSmallButton +import support.views.createNotification import support.views.createPrimaryButton import support.views.createPrimarySmallButton @@ -54,13 +54,19 @@ class InvitationsView( addSortableColumn("저장소 전체 이름", InvitationResponse::repositoryFullName) addSortableDateTimeColumn("초대 일시", InvitationResponse::invitationDateTime) addColumn(createButtonRenderer()).apply { isAutoWidth = true } - invitationService.findAll().also { + fetchInvitations().also { setItems(it) columns.first().setFooter("총 ${it.size}개") } } } + private fun fetchInvitations(): List { + return runCatching { invitationService.findAll() } + .onFailure { createNotification(it.localizedMessage) } + .getOrDefault(emptyList()) + } + private fun createButtonRenderer(): Renderer { return ComponentRenderer { it -> createButtons(it) } }