INFRA-2701 | Dhruv | use github client sdk
This commit is contained in:
10
pom.xml
10
pom.xml
@@ -138,6 +138,16 @@
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.13</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<version>2.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.spotify</groupId>
|
||||
<artifactId>github-client</artifactId>
|
||||
<version>0.2.17</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<artifactId>httpmime</artifactId>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.navi.infra.portal.configuration;
|
||||
|
||||
import com.spotify.github.v3.clients.GitHubClient;
|
||||
import java.net.URI;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class GitHubClientConfig {
|
||||
|
||||
@Bean
|
||||
public GitHubClient githubClient(@Value("${github.token}") String authToken) {
|
||||
return GitHubClient.create(URI.create("https://api.github.com/"), authToken);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,13 @@ import com.navi.infra.portal.util.KubernetesManifestGenerator;
|
||||
import com.navi.infra.portal.util.MapDiffUtil;
|
||||
import com.navi.infra.portal.util.gocd.GocdConfigValidatorUtil;
|
||||
import com.navi.infra.portal.util.gocd.PipelineValidatorUtil;
|
||||
import com.navi.infra.portal.v2.client.github.GitHubApiRequest;
|
||||
import com.navi.infra.portal.v2.client.github.GitHubApiResponse;
|
||||
import com.navi.infra.portal.v2.client.github.GitHubClient;
|
||||
import com.navi.infra.portal.v2.exception.GocdValidationException;
|
||||
import com.navi.infra.portal.v2.exception.PipelineDifferenceException;
|
||||
import com.spotify.github.v3.clients.GitHubClient;
|
||||
import com.spotify.github.v3.clients.RepositoryClient;
|
||||
import com.spotify.github.v3.repos.Content;
|
||||
import com.spotify.github.v3.repos.requests.FileCreate;
|
||||
import com.spotify.github.v3.repos.requests.ImmutableFileCreate;
|
||||
import com.sun.jdi.request.InvalidRequestStateException;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
@@ -34,7 +36,6 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -49,9 +50,11 @@ public class PipelineManifestService {
|
||||
private final String PIPELINE_PATH = "pipelines";
|
||||
private final String PIPELINE_MANIFEST_FILE_NAME = "pipeline_manifest.json";
|
||||
private final String PIPELINE_FILE_NAME = "pipelines.gocd.yaml";
|
||||
private final String orgName;
|
||||
|
||||
private final String repository = "test-github-action-deployment-portal-backend";
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectMapper yamlMapper;
|
||||
private final RepositoryClient repositoryClient;
|
||||
|
||||
private final PipelineManifestRepository pipelineManifestRepository;
|
||||
private final KubernetesManifestGenerator kubernetesManifestGenerator;
|
||||
@@ -60,16 +63,15 @@ public class PipelineManifestService {
|
||||
private final GocdConfigValidatorUtil gocdConfigValidatorUtil;
|
||||
private final PipelineValidatorUtil pipelineValidatorUtil;
|
||||
private final MapDiffUtil mapDiffUtil;
|
||||
private final GitHubClient gitHubClient;
|
||||
|
||||
PipelineManifestService(
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("yamlMapper") ObjectMapper yamlMapper,
|
||||
GitHubClient githubClient,
|
||||
PipelineManifestRepository pipelineManifestRepository,
|
||||
KubernetesManifestGenerator kubernetesManifestGenerator,
|
||||
ManifestService manifestService,
|
||||
AuthorizationService authorizationFilter,
|
||||
GitHubClient gitHubClient,
|
||||
PipelineValidatorUtil pipelineValidatorUtil,
|
||||
GocdConfigValidatorUtil gocdConfigValidatorUtil,
|
||||
@Value("${github.org.name}") String orgName,
|
||||
@@ -82,11 +84,10 @@ public class PipelineManifestService {
|
||||
this.kubernetesManifestGenerator = kubernetesManifestGenerator;
|
||||
this.manifestService = manifestService;
|
||||
this.authorizationFilter = authorizationFilter;
|
||||
this.gitHubClient = gitHubClient;
|
||||
this.pipelineValidatorUtil = pipelineValidatorUtil;
|
||||
this.gocdConfigValidatorUtil = gocdConfigValidatorUtil;
|
||||
this.orgName = orgName;
|
||||
this.mapDiffUtil = mapDiffUtil;
|
||||
this.repositoryClient = githubClient.createRepositoryClient(orgName, repository);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +148,7 @@ public class PipelineManifestService {
|
||||
|
||||
private void validatePipelineDifference(
|
||||
PipelineManifest pipelineManifestRequest,
|
||||
GitHubApiResponse fetchedPipeline
|
||||
Content fetchedPipeline
|
||||
) {
|
||||
PipelineManifest existingPipelineManifest = getPipelineManifest(
|
||||
pipelineManifestRequest.getName(), Gocd.MERGED.name());
|
||||
@@ -155,30 +156,30 @@ public class PipelineManifestService {
|
||||
return;
|
||||
}
|
||||
if ((existingPipelineManifest == null ^ fetchedPipeline == null)
|
||||
|| manualChangesInPipeline(existingPipelineManifest, fetchedPipeline.getContent())) {
|
||||
|| manualChangesInPipeline(existingPipelineManifest, fetchedPipeline.content())) {
|
||||
throw new PipelineDifferenceException(String.format(
|
||||
"Manual changes detected in pipeline %s. Please update the pipeline through github.",
|
||||
pipelineManifestRequest.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
private String getPipeLineCreationMessage(GitHubApiResponse response) {
|
||||
private String getPipeLineCreationMessage(Content response) {
|
||||
return String.format("Pipeline %s by %s at %s", response == null ? "created" : "updated",
|
||||
getUserEmail(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
private void createOrUpdatePipelineInGitHub(
|
||||
String filePath, GitHubApiResponse content, Map<String, Object> pipelineMap
|
||||
String filePath, Content content, Map<String, Object> pipelineMap
|
||||
) {
|
||||
try {
|
||||
String encodedContent = base64Encode(yamlMapper.writeValueAsString(pipelineMap));
|
||||
String message = getPipeLineCreationMessage(content);
|
||||
String sha = Optional.ofNullable(content)
|
||||
.map(GitHubApiResponse::getSha)
|
||||
.orElse(null);
|
||||
GitHubApiRequest githubRequest = new GitHubApiRequest(sha, message,
|
||||
encodedContent, "master");
|
||||
gitHubClient.createOrUpdateFileContent(orgName, filePath, githubRequest);
|
||||
FileCreate fileCreate = ImmutableFileCreate.builder()
|
||||
.message(message)
|
||||
.content(encodedContent)
|
||||
.branch("master")
|
||||
.build();
|
||||
repositoryClient.createFileContent(filePath, fileCreate);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e.getMessage());
|
||||
}
|
||||
@@ -203,7 +204,12 @@ public class PipelineManifestService {
|
||||
|
||||
private Map<String, Object> generateGocdPipeline(PipelineManifest pipelineManifest) {
|
||||
String filePath = String.format("%s.gocd.yaml", pipelineManifest.getName());
|
||||
GitHubApiResponse fetchedPipeline = gitHubClient.getFileContent(orgName, filePath);
|
||||
Content fetchedPipeline;
|
||||
try {
|
||||
fetchedPipeline = repositoryClient.getFileContent(filePath).get();
|
||||
} catch (Exception e) {
|
||||
fetchedPipeline = null;
|
||||
}
|
||||
var createdPipelineManifestRequest = pipelineManifestRepository.save(pipelineManifest);
|
||||
validatePipelineDifference(pipelineManifest, fetchedPipeline);
|
||||
Map<String, Object> pipelineMap = generatePipelineTemplate(pipelineManifest);
|
||||
|
||||
@@ -31,6 +31,7 @@ public class GocdConfigValidatorUtil {
|
||||
}
|
||||
|
||||
public boolean validate(String filePath) throws Exception {
|
||||
validateFilePath(filePath);
|
||||
Path path = Paths.get(filePath);
|
||||
if (!Files.exists(path)) {
|
||||
throw new FileNotFoundException("File not found: " + filePath);
|
||||
@@ -42,4 +43,11 @@ public class GocdConfigValidatorUtil {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void validateFilePath(String filePath) {
|
||||
Path path = Paths.get(filePath).normalize();
|
||||
if (path.isAbsolute() || path.toString().contains("..")) {
|
||||
throw new IllegalArgumentException("Invalid file path: " + filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.navi.infra.portal.v2.client.github;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GitHubApiRequest {
|
||||
|
||||
private String sha;
|
||||
private String message;
|
||||
private String content;
|
||||
private String branch;
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.navi.infra.portal.v2.client.github;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class GitHubApiResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 8769281411503539181L;
|
||||
private String name;
|
||||
private String path;
|
||||
private String sha;
|
||||
private String content;
|
||||
private String encoding;
|
||||
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.navi.infra.portal.v2.client.github;
|
||||
|
||||
import static java.lang.String.format;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class GitHubClient {
|
||||
|
||||
private final String authToken;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
// TODO change to gocd-pipelines
|
||||
private final String repository = "test-github-action-deployment-portal-backend";
|
||||
|
||||
public GitHubClient(
|
||||
@Value("${github.token}") String authToken,
|
||||
@Qualifier("jsonMapper") ObjectMapper objectMapper
|
||||
) {
|
||||
this.authToken = authToken;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newHttpClient();
|
||||
}
|
||||
|
||||
private HttpRequest.Builder createRequestBuilder(String url) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "token " + this.authToken)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.header("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
|
||||
String filePathUrl(String org, String repository, String filePath) {
|
||||
return format("https://api.github.com/repos/%s/%s/contents/%s", org, repository, filePath);
|
||||
}
|
||||
|
||||
public GitHubApiResponse getFileContent(String org, String filePath) {
|
||||
String url = filePathUrl(org, repository, filePath);
|
||||
try {
|
||||
HttpRequest request = createRequestBuilder(url).GET().build();
|
||||
HttpResponse<String> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == HttpStatus.OK.value()) {
|
||||
log.info("Successfully retrieved file content from GitHub for {}", filePath);
|
||||
return objectMapper.readValue(response.body(), GitHubApiResponse.class);
|
||||
}
|
||||
if (response.statusCode() == HttpStatus.NOT_FOUND.value()) {
|
||||
log.error("File not found in GitHub : {}", response.body());
|
||||
return null;
|
||||
}
|
||||
throw new RuntimeException("Failed to access content from GitHub.");
|
||||
} catch (Exception e) {
|
||||
log.error("Error occurred while getting file from filePath {} : {}",
|
||||
filePath, e.getMessage());
|
||||
throw new RuntimeException("Failed to get file from GitHub", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void createOrUpdateFileContent(
|
||||
String org,
|
||||
String filePath,
|
||||
GitHubApiRequest githubRequest
|
||||
) {
|
||||
String url = filePathUrl(org, repository, filePath);
|
||||
try {
|
||||
String payload = objectMapper.writeValueAsString(githubRequest);
|
||||
HttpRequest request = createRequestBuilder(url).PUT(
|
||||
HttpRequest.BodyPublishers.ofString(payload)).build();
|
||||
HttpResponse<String> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == HttpStatus.OK.value()) {
|
||||
log.info("Successfully updated file in GitHub.");
|
||||
return;
|
||||
}
|
||||
if (response.statusCode() == HttpStatus.CREATED.value()) {
|
||||
log.info("Successfully created file in GitHub.");
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException(response.body());
|
||||
} catch (Exception e) {
|
||||
log.error("Error occurred while creating/updating file in {} org with path {} : {}",
|
||||
org, filePath, e.getMessage());
|
||||
throw new RuntimeException("Failed to commit changes to GitHub", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import static com.navi.infra.portal.v2.privilege.ResourceType.MANIFEST;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -24,15 +24,17 @@ import com.navi.infra.portal.util.KutegenClient;
|
||||
import com.navi.infra.portal.util.MapDiffUtil;
|
||||
import com.navi.infra.portal.util.gocd.GocdConfigValidatorUtil;
|
||||
import com.navi.infra.portal.util.gocd.PipelineValidatorUtil;
|
||||
import com.navi.infra.portal.v2.client.github.GitHubApiResponse;
|
||||
import com.navi.infra.portal.v2.client.github.GitHubClient;
|
||||
import com.navi.infra.portal.v2.client.gocd.GocdClient;
|
||||
import com.navi.infra.portal.v2.exception.PipelineDifferenceException;
|
||||
import com.spotify.github.v3.clients.GitHubClient;
|
||||
import com.spotify.github.v3.clients.RepositoryClient;
|
||||
import com.spotify.github.v3.repos.ImmutableContent;
|
||||
import com.sun.jdi.request.InvalidRequestStateException;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -64,9 +66,12 @@ public class PipelineManifestServiceTest extends ExternalIntegrationProvider {
|
||||
PipelineManifest oldPipelineManifestWithId;
|
||||
PipelineManifest newPipelineManifestWithId;
|
||||
Manifest manifest;
|
||||
|
||||
@Mock
|
||||
GitHubClient gitHubClient;
|
||||
@Mock
|
||||
RepositoryClient repositoryClient;
|
||||
@Mock
|
||||
GocdClient gocdClient;
|
||||
private PipelineManifestService pipelineManifestService;
|
||||
@Mock
|
||||
@@ -92,14 +97,16 @@ public class PipelineManifestServiceTest extends ExternalIntegrationProvider {
|
||||
|
||||
@BeforeEach
|
||||
public void init() throws IOException {
|
||||
when(gitHubClient.createRepositoryClient(anyString(), anyString())).thenReturn(
|
||||
repositoryClient);
|
||||
pipelineManifestService = new PipelineManifestService(
|
||||
objectMapper,
|
||||
yamlMapper,
|
||||
gitHubClient,
|
||||
pipelineManifestRepository,
|
||||
kutegenClient,
|
||||
manifestService,
|
||||
authorizationService,
|
||||
gitHubClient,
|
||||
pipelineValidatorUtil,
|
||||
gocdConfigValidatorUtil,
|
||||
"navi-infra",
|
||||
@@ -190,16 +197,15 @@ public class PipelineManifestServiceTest extends ExternalIntegrationProvider {
|
||||
Manifest manifest = new Manifest();
|
||||
|
||||
// base64 encoded string which isn't a valid yaml
|
||||
var gitHubApiResponse = new GitHubApiResponse(null, null,
|
||||
"4827ca65e48152eb304c4fe39d7baea3e13856c2",
|
||||
"some-invalid-content-from-github-that-isn't-base-64", "base64");
|
||||
var content = ImmutableContent.builder().content("some-invalid-content-from-github")
|
||||
.sha("4827ca65e48152eb304c4fe39d7baea3e13856c2").encoding("base64").build();
|
||||
|
||||
when(manifestService.fetchById(manifestId)).thenReturn(manifest);
|
||||
when(authorizationService.hasPermissions(MANIFEST, manifest, MANIFEST_WRITE)).thenReturn(
|
||||
true);
|
||||
when(pipelineValidatorUtil.isValidPipeline(any())).thenReturn(true);
|
||||
|
||||
when(gitHubClient.getFileContent(any(), any())).thenReturn(gitHubApiResponse);
|
||||
when(repositoryClient.getFileContent(any())).thenReturn(
|
||||
CompletableFuture.completedFuture(content));
|
||||
when(pipelineManifestRepository.save(newPipelineManifest)).thenReturn(
|
||||
newPipelineManifestWithId);
|
||||
when(pipelineManifestRepository.findLatestByNameAndStatus(any(), any()))
|
||||
@@ -215,16 +221,15 @@ public class PipelineManifestServiceTest extends ExternalIntegrationProvider {
|
||||
Manifest manifest = new Manifest();
|
||||
|
||||
// base64 encoded string which isn't same as the pipeline manifest yaml
|
||||
var gitHubApiResponse = new GitHubApiResponse(null, null,
|
||||
"4827ca65e48152eb304c4fe39d7baea3e13856c2",
|
||||
inconsistentBase64EncodedYaml, "base64");
|
||||
var content = ImmutableContent.builder().content(inconsistentBase64EncodedYaml)
|
||||
.sha("4827ca65e48152eb304c4fe39d7baea3e13856c2").encoding("base64").build();
|
||||
|
||||
when(manifestService.fetchById(manifestId)).thenReturn(manifest);
|
||||
when(authorizationService.hasPermissions(MANIFEST, manifest, MANIFEST_WRITE)).thenReturn(
|
||||
true);
|
||||
when(pipelineValidatorUtil.isValidPipeline(any())).thenReturn(true);
|
||||
|
||||
when(gitHubClient.getFileContent(any(), any())).thenReturn(gitHubApiResponse);
|
||||
when(repositoryClient.getFileContent(any())).thenReturn(
|
||||
CompletableFuture.completedFuture(content));
|
||||
when(pipelineManifestRepository.save(newPipelineManifest)).thenReturn(
|
||||
newPipelineManifestWithId);
|
||||
when(pipelineManifestRepository.findLatestByNameAndStatus(any(), any()))
|
||||
@@ -242,16 +247,17 @@ public class PipelineManifestServiceTest extends ExternalIntegrationProvider {
|
||||
Manifest manifest = new Manifest();
|
||||
|
||||
// base64 encoded string which is same as the old pipeline manifest yaml
|
||||
var gitHubApiResponse = new GitHubApiResponse(null, null,
|
||||
"4827ca65e48152eb304c4fe39d7baea3e13856c2",
|
||||
oldBase64EncodedPipelineYaml, "base64");
|
||||
var content = ImmutableContent.builder().content(oldBase64EncodedPipelineYaml)
|
||||
.sha("4827ca65e48152eb304c4fe39d7baea3e13856c2").encoding("base64").build();
|
||||
|
||||
when(manifestService.fetchById(manifestId)).thenReturn(manifest);
|
||||
when(authorizationService.hasPermissions(MANIFEST, manifest, MANIFEST_WRITE)).thenReturn(
|
||||
true);
|
||||
when(pipelineValidatorUtil.isValidPipeline(any())).thenReturn(true);
|
||||
|
||||
when(gitHubClient.getFileContent(any(), any())).thenReturn(gitHubApiResponse);
|
||||
when(repositoryClient.getFileContent(any())).thenReturn(
|
||||
CompletableFuture.completedFuture(content));
|
||||
|
||||
when(pipelineManifestRepository.findLatestByNameAndStatus(any(), any()))
|
||||
.thenReturn(Optional.of(oldPipelineManifestWithId));
|
||||
try {
|
||||
@@ -259,7 +265,8 @@ public class PipelineManifestServiceTest extends ExternalIntegrationProvider {
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
doNothing().when(gitHubClient).createOrUpdateFileContent(any(), any(), any());
|
||||
when(repositoryClient.createFileContent(any(), any())).thenReturn(
|
||||
CompletableFuture.completedFuture(any()));
|
||||
newPipelineManifest.setStatus(Gocd.MERGED.name());
|
||||
when(pipelineManifestRepository.save(newPipelineManifest)).thenReturn(
|
||||
newPipelineManifestWithId);
|
||||
|
||||
Reference in New Issue
Block a user