-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 소셜 로그인 및 회원 인증·프로필 구현 #6
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
Merged
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
416e900
chore: 소셜 로그인 의존성 및 설정 골격 추가
theminjunchoi 30661e1
feat: 공통 응답 및 예외 처리 추가
theminjunchoi 9832595
feat: Member 도메인 및 리포지토리 추가
theminjunchoi 10c437a
feat: JWT 발급·검증 구현
theminjunchoi 49f025b
feat: OAuthClient 인터페이스 및 Google/Apple 검증 구현
theminjunchoi bab7917
feat: 회원 조회·가입 서비스 추가
theminjunchoi 0f273ee
refactor: 도메인 순수성 및 provider enum 구조 정리
theminjunchoi b652c91
feat: 소셜 로그인 및 토큰 재발급 구현
theminjunchoi f76d54b
feat: Spring Security 설정 및 JWT 인증 필터 추가
theminjunchoi 0d81123
feat: Swagger(springdoc) API 문서화 추가
theminjunchoi db28a5d
chore: Flyway 도입 및 환경별 설정 프로필 분리
theminjunchoi 446278b
chore: 미사용 dev·prod 설정 프로필 제거
theminjunchoi 0629366
refactor: DTO를 파일 단위로 분리
theminjunchoi e89471c
refactor: else 제거하고 early return으로 정리
theminjunchoi c940599
test: 테스트 커버리지 보강 및 OAuth 클라이언트 테스트 용이성 개선
theminjunchoi a78770c
feat: 회원 닉네임 추가 및 수정 기능 구현
theminjunchoi 9e3c870
feat: 회원 수정 시각 기록 및 소프트 삭제 탈퇴 구현
theminjunchoi 8b25a3b
feat: 프로필 응답 보강 및 닉네임 정책 강화
theminjunchoi f8acd46
refactor: Nickname init 검증 로직을 함수로 분리
theminjunchoi 9b9f5de
refactor: 함수 순서 정리 및 긴 함수 분리
theminjunchoi aaded75
chore: 로컬 실행 시 docker-compose 자동 기동 지원 추가
theminjunchoi c155de5
docs: Swagger API 문서 설명 보강
theminjunchoi 4431054
fix: refresh 토큰으로 인증이 통과하던 문제 수정
theminjunchoi 617b25b
fix: 동시 최초 로그인 시 유니크 충돌로 500이 나던 문제 수정
theminjunchoi ce30db2
refactor: 인증 책임 분리 — 오케스트레이션·재시도·트랜잭션 작업
theminjunchoi 5b79ba8
fix: OAuth client-ids 누락 시 aud 검증이 꺼지던 문제 수정
theminjunchoi 6913e37
fix: 리프레시 토큰을 해시해서 저장
theminjunchoi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # 로컬 개발용 MySQL. 실행: docker compose up -d | ||
| services: | ||
| mysql: | ||
| image: mysql:8.0 | ||
| container_name: gamss-mysql | ||
| ports: | ||
| - "3306:3306" | ||
| environment: | ||
| MYSQL_DATABASE: gamss | ||
| MYSQL_USER: gamss | ||
| MYSQL_PASSWORD: gamss | ||
| MYSQL_ROOT_PASSWORD: root | ||
| volumes: | ||
| - gamss-mysql-data:/var/lib/mysql | ||
| restart: unless-stopped | ||
|
|
||
| volumes: | ||
| gamss-mysql-data: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
54 changes: 54 additions & 0 deletions
54
src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package com.nexters.gamss.auth.controller | ||
|
|
||
| import com.nexters.gamss.auth.controller.dto.LoginRequest | ||
| import com.nexters.gamss.auth.controller.dto.ReissueRequest | ||
| import com.nexters.gamss.auth.controller.dto.TokenResponse | ||
| import com.nexters.gamss.auth.oauth.OAuthProvider | ||
| import com.nexters.gamss.auth.service.AuthService | ||
| import com.nexters.gamss.global.response.ApiResponse | ||
| import io.swagger.v3.oas.annotations.Operation | ||
| import io.swagger.v3.oas.annotations.Parameter | ||
| import io.swagger.v3.oas.annotations.tags.Tag | ||
| import jakarta.validation.Valid | ||
| import org.springframework.web.bind.annotation.PathVariable | ||
| import org.springframework.web.bind.annotation.PostMapping | ||
| import org.springframework.web.bind.annotation.RequestBody | ||
| import org.springframework.web.bind.annotation.RequestMapping | ||
| import org.springframework.web.bind.annotation.RestController | ||
|
|
||
| @Tag(name = "인증", description = "소셜 로그인 및 토큰 재발급 API") | ||
| @RestController | ||
| @RequestMapping("/api/auth") | ||
| class AuthController( | ||
| private val authService: AuthService, | ||
| ) { | ||
| @Operation( | ||
| summary = "소셜 로그인", | ||
| description = | ||
| "앱이 소셜 SDK로 받은 id_token을 검증해 회원을 조회·가입하고 " + | ||
| "서비스 토큰(accessToken·refreshToken)을 발급합니다. 최초 로그인 시 회원이 자동 생성됩니다.", | ||
| ) | ||
| @PostMapping("/login/{provider}") | ||
| fun login( | ||
| @Parameter(description = "소셜 제공자 (google 또는 apple)", example = "google") | ||
| @PathVariable provider: String, | ||
| @Valid @RequestBody request: LoginRequest, | ||
| ): ApiResponse<TokenResponse> { | ||
| val result = authService.login(OAuthProvider.from(provider), request.idToken) | ||
| return ApiResponse.success(TokenResponse.from(result)) | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "토큰 재발급", | ||
| description = | ||
| "refreshToken으로 accessToken·refreshToken을 재발급합니다. " + | ||
| "refreshToken은 회전(rotate)되어 이전 토큰은 무효화됩니다.", | ||
| ) | ||
| @PostMapping("/reissue") | ||
| fun reissue( | ||
| @Valid @RequestBody request: ReissueRequest, | ||
| ): ApiResponse<TokenResponse> { | ||
| val result = authService.reissue(request.refreshToken) | ||
| return ApiResponse.success(TokenResponse.from(result)) | ||
| } | ||
| } |
10 changes: 10 additions & 0 deletions
10
src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.nexters.gamss.auth.controller.dto | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema | ||
| import jakarta.validation.constraints.NotBlank | ||
|
|
||
| data class LoginRequest( | ||
| @field:NotBlank(message = "idToken은 필수입니다.") | ||
| @field:Schema(description = "소셜 SDK로 발급받은 idToken(JWT)", example = "eyJhbGciOiJSUzI1NiIsImtpZCI6...") | ||
| val idToken: String, | ||
| ) |
10 changes: 10 additions & 0 deletions
10
src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.nexters.gamss.auth.controller.dto | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema | ||
| import jakarta.validation.constraints.NotBlank | ||
|
|
||
| data class ReissueRequest( | ||
| @field:NotBlank(message = "refreshToken은 필수입니다.") | ||
| @field:Schema(description = "로그인 시 발급받은 refreshToken", example = "eyJhbGciOiJIUzI1NiJ9...") | ||
| val refreshToken: String, | ||
| ) |
15 changes: 15 additions & 0 deletions
15
src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.nexters.gamss.auth.controller.dto | ||
|
|
||
| import com.nexters.gamss.auth.service.TokenResult | ||
| import io.swagger.v3.oas.annotations.media.Schema | ||
|
|
||
| data class TokenResponse( | ||
| @field:Schema(description = "액세스 토큰 (Authorization 헤더에 Bearer로 사용)") | ||
| val accessToken: String, | ||
| @field:Schema(description = "리프레시 토큰 (재발급에 사용)") | ||
| val refreshToken: String, | ||
| ) { | ||
| companion object { | ||
| fun from(result: TokenResult): TokenResponse = TokenResponse(result.accessToken, result.refreshToken) | ||
| } | ||
| } |
36 changes: 36 additions & 0 deletions
36
src/main/kotlin/com/nexters/gamss/auth/domain/RefreshToken.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package com.nexters.gamss.auth.domain | ||
|
|
||
| import jakarta.persistence.Column | ||
| import jakarta.persistence.Entity | ||
| import jakarta.persistence.GeneratedValue | ||
| import jakarta.persistence.GenerationType | ||
| import jakarta.persistence.Id | ||
| import jakarta.persistence.Table | ||
| import jakarta.persistence.UniqueConstraint | ||
|
|
||
| /** | ||
| * 회원별 현재 유효한 리프레시 토큰. 재발급 시 회전(rotate)한다. | ||
| */ | ||
| @Entity | ||
| @Table( | ||
| name = "refresh_tokens", | ||
| uniqueConstraints = [ | ||
| UniqueConstraint(name = "uk_refresh_member", columnNames = ["member_id"]), | ||
| ], | ||
| ) | ||
| class RefreshToken( | ||
| @Column(name = "member_id", nullable = false) | ||
| val memberId: Long, | ||
| @Column(name = "token", nullable = false, length = 512) | ||
| var token: String, | ||
| ) { | ||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long = 0L | ||
|
|
||
| fun rotate(token: String) { | ||
| this.token = token | ||
| } | ||
|
|
||
| fun matches(token: String): Boolean = this.token == token | ||
| } | ||
33 changes: 33 additions & 0 deletions
33
src/main/kotlin/com/nexters/gamss/auth/domain/SocialAccount.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.nexters.gamss.auth.domain | ||
|
|
||
| import jakarta.persistence.Column | ||
| import jakarta.persistence.Entity | ||
| import jakarta.persistence.GeneratedValue | ||
| import jakarta.persistence.GenerationType | ||
| import jakarta.persistence.Id | ||
| import jakarta.persistence.Table | ||
| import jakarta.persistence.UniqueConstraint | ||
|
|
||
| /** | ||
| * 회원의 소셜 로그인 계정. (provider, providerId)로 유일하며, 회원은 memberId(ID 참조)로 연결한다. | ||
| * provider 는 문자열로 저장한다 — 제공자가 추가돼도 이 도메인 코드는 바뀌지 않는다. | ||
| */ | ||
| @Entity | ||
| @Table( | ||
| name = "social_accounts", | ||
| uniqueConstraints = [ | ||
| UniqueConstraint(name = "uk_social_provider", columnNames = ["provider", "provider_id"]), | ||
| ], | ||
| ) | ||
|
theminjunchoi marked this conversation as resolved.
|
||
| class SocialAccount( | ||
| @Column(name = "member_id", nullable = false) | ||
| val memberId: Long, | ||
| @Column(name = "provider", nullable = false, length = 30) | ||
| val provider: String, | ||
| @Column(name = "provider_id", nullable = false) | ||
| val providerId: String, | ||
| ) { | ||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long = 0L | ||
| } | ||
14 changes: 14 additions & 0 deletions
14
src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| /** | ||
| * 허용된 대상(aud) 목록 일급 컬렉션. 토큰의 audience 가 허용 목록에 포함되는지 판단한다. | ||
| * | ||
| * 허용 목록이 비어 있으면(설정 누락) 어떤 audience 도 통과시키지 않는다(fail-closed). | ||
| * 설정을 빠뜨렸을 때 aud 검증이 조용히 꺼지는 대신 로그인이 막혀 문제가 드러나도록 한다. | ||
| * dev/prod 에서 목록이 비는 것은 OAuthClientIdsValidator 가 기동 시점에 먼저 막는다. | ||
| */ | ||
| class AllowedAudiences( | ||
| private val values: List<String>, | ||
| ) { | ||
| fun accepts(audiences: List<String>?): Boolean = !audiences.isNullOrEmpty() && audiences.any { it in values } | ||
| } |
18 changes: 18 additions & 0 deletions
18
src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| import org.springframework.beans.factory.annotation.Autowired | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| @Component | ||
| class AppleOAuthClient( | ||
| private val verifier: OidcTokenVerifier, | ||
| ) : OAuthClient { | ||
| @Autowired | ||
| constructor(properties: OAuthProperties) : this( | ||
| OidcTokenVerifier(properties.apple, JwkSources.remote(properties.apple.jwksUri)), | ||
| ) | ||
|
|
||
| override val provider = OAuthProvider.APPLE | ||
|
|
||
| override fun verify(idToken: String): OAuthUserInfo = verifier.verify(idToken) | ||
| } |
18 changes: 18 additions & 0 deletions
18
src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| import org.springframework.beans.factory.annotation.Autowired | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| @Component | ||
| class GoogleOAuthClient( | ||
| private val verifier: OidcTokenVerifier, | ||
| ) : OAuthClient { | ||
| @Autowired | ||
| constructor(properties: OAuthProperties) : this( | ||
| OidcTokenVerifier(properties.google, JwkSources.remote(properties.google.jwksUri)), | ||
| ) | ||
|
|
||
| override val provider = OAuthProvider.GOOGLE | ||
|
|
||
| override fun verify(idToken: String): OAuthUserInfo = verifier.verify(idToken) | ||
| } |
13 changes: 13 additions & 0 deletions
13
src/main/kotlin/com/nexters/gamss/auth/oauth/JwkSources.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| import com.nimbusds.jose.jwk.source.JWKSource | ||
| import com.nimbusds.jose.jwk.source.JWKSourceBuilder | ||
| import com.nimbusds.jose.proc.SecurityContext | ||
| import java.net.URI | ||
|
|
||
| /** | ||
| * 원격 JWKS 엔드포인트로부터 공개키를 가져오는 JWKSource 생성기(캐싱 포함). | ||
| */ | ||
| object JwkSources { | ||
| fun remote(uri: String): JWKSource<SecurityContext> = JWKSourceBuilder.create<SecurityContext>(URI(uri).toURL()).build() | ||
| } |
10 changes: 10 additions & 0 deletions
10
src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| /** | ||
| * 소셜 제공자별 토큰 검증 전략. 새 제공자는 이 인터페이스 구현체(@Component)만 추가하면 된다(OCP). | ||
| */ | ||
| interface OAuthClient { | ||
| val provider: OAuthProvider | ||
|
|
||
| fun verify(idToken: String): OAuthUserInfo | ||
| } |
41 changes: 41 additions & 0 deletions
41
src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidator.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| import org.springframework.beans.factory.InitializingBean | ||
| import org.springframework.core.env.Environment | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| /** | ||
| * dev/prod 기동 시 OAuth client-ids 가 비어 있으면 부팅을 실패시킨다. | ||
| * | ||
| * client-ids 가 비면 aud 검증이 fail-closed 로 막혀 모든 로그인이 실패한다(AllowedAudiences). | ||
| * 그 상태로 배포된 걸 런타임에야 발견하지 않도록, 설정 누락을 기동 시점에 크게 드러낸다 — | ||
| * "모든 로그인 실패"보다 "서버가 안 뜸"이 더 빨리 잡힌다. | ||
| * | ||
| * local/test 프로필은 검증 없이 뜰 수 있게 둔다(설정 없이 로컬 구동·테스트 허용). | ||
| */ | ||
| @Component | ||
| class OAuthClientIdsValidator( | ||
| private val properties: OAuthProperties, | ||
| private val environment: Environment, | ||
| ) : InitializingBean { | ||
| override fun afterPropertiesSet() { | ||
| if (isConfigOptional()) { | ||
| return | ||
| } | ||
| require(properties.google.clientIds.isNotEmpty()) { missingMessage("GOOGLE_CLIENT_IDS") } | ||
| require(properties.apple.clientIds.isNotEmpty()) { missingMessage("APPLE_CLIENT_IDS") } | ||
| } | ||
|
|
||
| private fun isConfigOptional(): Boolean { | ||
| val active = environment.activeProfiles | ||
| return active.isEmpty() || active.all { it in CONFIG_OPTIONAL_PROFILES } | ||
| } | ||
|
|
||
| private fun missingMessage(key: String): String = | ||
| "$key 가 비어 있습니다. 활성 프로필(${environment.activeProfiles.joinToString()})에서는 " + | ||
| "소셜 로그인 aud 검증을 위해 필수입니다." | ||
|
|
||
| companion object { | ||
| private val CONFIG_OPTIONAL_PROFILES = setOf("local", "test") | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolver.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.nexters.gamss.auth.oauth | ||
|
|
||
| import com.nexters.gamss.global.exception.BusinessException | ||
| import com.nexters.gamss.global.exception.ErrorCode | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| /** | ||
| * 등록된 OAuthClient 들을 provider 로 매핑한다(일급 컬렉션). | ||
| * 새 제공자 구현체(@Component)를 추가하면 자동으로 등록된다(OCP). | ||
| */ | ||
| @Component | ||
| class OAuthClientResolver( | ||
| clients: List<OAuthClient>, | ||
| ) { | ||
| private val clientsByProvider: Map<OAuthProvider, OAuthClient> = clients.associateBy { it.provider } | ||
|
|
||
| fun resolve(provider: OAuthProvider): OAuthClient = | ||
| clientsByProvider[provider] | ||
| ?: throw BusinessException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.