Unverified Commit 319891db authored by Greg Messner's avatar Greg Messner Committed by GitHub
Browse files

Added support for Project Import/Export API - #377 (#379)

parent c9fe3159
......@@ -57,7 +57,7 @@
<changelog-lib.version>1.59</changelog-lib.version>
<gitlab.version>11.10.4-ce.0</gitlab.version>
<gitlab.version>11.11.2-ce.0</gitlab.version>
<gitlab.autoremove-container>true</gitlab.autoremove-container>
<gitlab.skip-docker-start>true</gitlab.skip-docker-start>
<gitlab.port>8090</gitlab.port>
......
......@@ -63,6 +63,7 @@ public class GitLabApi {
private EventsApi eventsApi;
private GroupApi groupApi;
private HealthCheckApi healthCheckApi;
private ImportExportApi importExportApi;
private IssuesApi issuesApi;
private JobApi jobApi;
private LabelsApi labelsApi;
......@@ -1123,11 +1124,30 @@ public class GitLabApi {
return (healthCheckApi);
}
/**
* Gets the ImportExportApi instance owned by this GitLabApi instance. The ImportExportApi is used
* to perform all project import/export related API calls.
*
* @return the ImportExportApi instance owned by this GitLabApi instance
*/
public ImportExportApi getImportExportApi() {
if (importExportApi == null) {
synchronized (this) {
if (importExportApi == null) {
importExportApi = new ImportExportApi(this);
}
}
}
return (importExportApi);
}
/**
* Gets the IssuesApi instance owned by this GitLabApi instance. The IssuesApi is used
* to perform all iossue related API calls.
* to perform all issue related API calls.
*
* @return the CommitsApi instance owned by this GitLabApi instance
* @return the IssuesApi instance owned by this GitLabApi instance
*/
public IssuesApi getIssuesApi() {
......
package org.gitlab4j.api;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Map;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.gitlab4j.api.models.ExportStatus;
import org.gitlab4j.api.models.ImportStatus;
import org.gitlab4j.api.models.Project;
/**
* This class provides an entry point to all the GitLab API project import/export calls.
* @see <a href="https://docs.gitlab.com/ee/api/project_import_export.html">Project import/export API at GitLab</a>
*/
public class ImportExportApi extends AbstractApi {
public ImportExportApi(GitLabApi gitLabApi) {
super(gitLabApi);
}
/**
* Schedule an export.
*
* <pre><code>GitLab Endpoint: POST /projects/:id/export</code></pre>
*
* @param projectIdOrPath the project in the form of an Integer(ID), String(path), or Project instance
* @throws GitLabApiException if any exception occurs
*/
public void scheduleExport(Object projectIdOrPath) throws GitLabApiException {
scheduleExport(projectIdOrPath, null, null, null, null);
}
/**
* Schedule an export.
*
* <pre><code>GitLab Endpoint: POST /projects/:id/export</code></pre>
*
* @param projectIdOrPath the project in the form of an Integer(ID), String(path), or Project instance
* @param description overrides the project description, optional
* @throws GitLabApiException if any exception occurs
*/
public void scheduleExport(Object projectIdOrPath, String description) throws GitLabApiException {
scheduleExport(projectIdOrPath, description, null, null, null);
}
/**
* Schedule an export.
*
* <pre><code>GitLab Endpoint: POST /projects/:id/export</code></pre>
*
* @param projectIdOrPath the project in the form of an Integer(ID), String(path), or Project instance
* @param description overrides the project description, optional
* @param upload Mao that contains the information to upload the exported project to a web server
* @param uploadUrl the URL to upload the project
* @param uploadHttpMethod the HTTP method to upload the exported project.
* Only PUT and POST methods allowed. Default is PUT
* @throws GitLabApiException if any exception occurs
*/
public void scheduleExport(Object projectIdOrPath, String description,
Map<String, String> upload, String uploadUrl, String uploadHttpMethod) throws GitLabApiException {
Form formData = new GitLabApiForm()
.withParam("description", description)
.withParam("upload", upload)
.withParam("upload[url]", uploadUrl)
.withParam("upload[http_method]", uploadHttpMethod);
post(Response.Status.ACCEPTED, formData, "projects", getProjectIdOrPath(projectIdOrPath), "export");
}
/**
* Get the status of export.
*
* <pre><code>GitLab Endpoint: GET /projects/:id/export</code></pre>
*
* @param projectIdOrPath the project in the form of an Integer(ID), String(path), or Project instance
* @return an ExportStatus instance holding information on the export status
* @throws GitLabApiException if any exception occurs
*/
public ExportStatus getExportStatus(Object projectIdOrPath) throws GitLabApiException {
Response response = get(Response.Status.OK, null, "projects", getProjectIdOrPath(projectIdOrPath), "export");
return (response.readEntity(ExportStatus.class));
}
/**
* Download the finished export.
*
* <pre><code>GitLab Endpoint: GET /projects/:id/export/download</code></pre>
*
* @param projectIdOrPath the project in the form of an Integer(ID), String(path), or Project instance
* @param directory the File instance of the directory to save the export file to, if null will use "java.io.tmpdir"
* @return a File instance pointing to the download of the project export file
* @throws GitLabApiException if any exception occurs
*/
public File downloadExport(Object projectIdOrPath, File directory) throws GitLabApiException {
Response response = getWithAccepts(Response.Status.OK, null, MediaType.MEDIA_TYPE_WILDCARD,
"projects", getProjectIdOrPath(projectIdOrPath), "export", "download");
try {
if (directory == null)
directory = new File(System.getProperty("java.io.tmpdir"));
String disposition = response.getHeaderString("Content-Disposition");
String filename = disposition.replaceFirst("(?i)^.*filename=\"?([^\"]+)\"?.*$", "$1");
File file = new File(directory, filename);
InputStream in = response.readEntity(InputStream.class);
Files.copy(in, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
return (file);
} catch (IOException ioe) {
throw new GitLabApiException(ioe);
}
}
/**
* <p>Import an exported project. The following properties on the Project instance
* are utilized in the creation of the new project:</p>
*<ul>
* <li>defaultBranch (optional) - master by default</li>
* <li>description (optional) - short project description</li>
* <li>visibility (optional) - Limit by visibility public, internal, or private</li>
* <li>visibilityLevel (optional)</li>
* <li>issuesEnabled (optional) - Enable issues for this project</li>
* <li>mergeMethod (optional) - Set the merge method used</li>
* <li>mergeRequestsEnabled (optional) - Enable merge requests for this project</li>
* <li>wikiEnabled (optional) - Enable wiki for this project</li>
* <li>snippetsEnabled (optional) - Enable snippets for this project</li>
* <li>jobsEnabled (optional) - Enable jobs for this project</li>
* <li>containerRegistryEnabled (optional) - Enable container registry for this project</li>
* <li>sharedRunnersEnabled (optional) - Enable shared runners for this project</li>
* <li>publicJobs (optional) - If true, jobs can be viewed by non-project-members</li>
* <li>onlyAllowMergeIfPipelineSucceeds (optional) - Set whether merge requests can only be merged with successful jobs</li>
* <li>onlyAllowMergeIfAllDiscussionsAreResolved (optional) - Set whether merge requests can only be merged when all the discussions are resolved</li>
* <li>lLfsEnabled (optional) - Enable LFS</li>
* <li>requestAccessEnabled (optional) - Allow users to request member access</li>
* <li>repositoryStorage (optional) - Which storage shard the repository is on. Available only to admins</li>
* <li>approvalsBeforeMerge (optional) - How many approvers should approve merge request by default</li>
* <li>printingMergeRequestLinkEnabled (optional) - Show link to create/view merge request when pushing from the command line</li>
* <li>resolveOutdatedDiffDiscussions (optional) - Automatically resolve merge request diffs discussions on lines changed with a push</li>
* <li>initialize_with_readme (optional) - Initialize project with README file</li>
* <li>packagesEnabled (optional) - Enable or disable mvn packages repository feature</li>
*</ul>
* <pre><code>GitLab Endpoint: POST /projects/import</code></pre>
*
* @param namespaceIdOrPath the ID or path of the namespace that the project will be imported to. Defaults to the current user’s namespace
* @param exportFile the project export file to be imported
* @param path the name and path for the new project
* @param overwrite if there is a project with the same path the import will overwrite it. Defaults to false
* @param overrideParams overriding project params, supports all fields defined by the ProjectApi, optional
* @return an Importstatus instance with info for the project being imported to
* @throws GitLabApiException if any exception occurs
*/
public ImportStatus startImport(Object namespaceIdOrPath, File exportFile, String path, Boolean overwrite, Project overrideParams) throws GitLabApiException {
URL url;
try {
url = getApiClient().getApiUrl("projects", "import");
} catch (IOException ioe) {
throw new GitLabApiException(ioe);
}
GitLabApiForm formData = new GitLabApiForm()
.withParam("path", path, true)
.withParam("namespace", namespaceIdOrPath)
.withParam("overwrite", overwrite);
if (overrideParams != null) {
formData.withParam("default_branch", overrideParams.getDefaultBranch())
.withParam("description", overrideParams.getDescription())
.withParam("issues_enabled", overrideParams.getIssuesEnabled())
.withParam("merge_method", overrideParams.getMergeMethod())
.withParam("merge_requests_enabled", overrideParams.getMergeRequestsEnabled())
.withParam("jobs_enabled", overrideParams.getJobsEnabled())
.withParam("wiki_enabled", overrideParams.getWikiEnabled())
.withParam("container_registry_enabled", overrideParams.getContainerRegistryEnabled())
.withParam("snippets_enabled", overrideParams.getSnippetsEnabled())
.withParam("shared_runners_enabled", overrideParams.getSharedRunnersEnabled())
.withParam("public_jobs", overrideParams.getPublicJobs())
.withParam("visibility_level", overrideParams.getVisibilityLevel())
.withParam("only_allow_merge_if_pipeline_succeeds", overrideParams.getOnlyAllowMergeIfPipelineSucceeds())
.withParam("only_allow_merge_if_all_discussions_are_resolved", overrideParams.getOnlyAllowMergeIfAllDiscussionsAreResolved())
.withParam("lfs_enabled", overrideParams.getLfsEnabled())
.withParam("request_access_enabled", overrideParams.getRequestAccessEnabled())
.withParam("repository_storage", overrideParams.getRepositoryStorage())
.withParam("approvals_before_merge", overrideParams.getApprovalsBeforeMerge())
.withParam("printing_merge_request_link_enabled", overrideParams.getPrintingMergeRequestLinkEnabled())
.withParam("resolve_outdated_diff_discussions", overrideParams.getResolveOutdatedDiffDiscussions())
.withParam("initialize_with_readme", overrideParams.getInitializeWithReadme())
.withParam("packages_enabled", overrideParams.getPackagesEnabled());
}
Response response = upload(Response.Status.CREATED, "file", exportFile, null, formData, url);
return (response.readEntity(ImportStatus.class));
}
/**
* Get the status of an import.
*
* <pre><code>GitLab Endpoint: GET /projects/:id/import</code></pre>
*
* @param projectIdOrPath the new (imported) project identifier in the form of an Integer(ID), String(path), or Project instance
* @return an ImportStatus instance holding information on the import status
* @throws GitLabApiException if any exception occurs
*/
public ImportStatus getImportStatus(Object projectIdOrPath) throws GitLabApiException {
Response response = get(Response.Status.OK, null, "projects", getProjectIdOrPath(projectIdOrPath), "import");
return (response.readEntity(ImportStatus.class));
}
}
package org.gitlab4j.api.models;
import java.util.Date;
import java.util.Map;
import org.gitlab4j.api.utils.JacksonJson;
import org.gitlab4j.api.utils.JacksonJsonEnumHelper;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
public class ExportStatus {
/**
* Enum representing the status of the export.
*/
public enum Status {
NONE, STARTED, FINISHED,
/**
* Represents that the export process has been completed successfully and the platform is
* performing some actions on the resulted file. For example, sending an email notifying
* the user to download the file, uploading the exported file to a web server, etc.
*/
AFTER_EXPORT_ACTION;
private static JacksonJsonEnumHelper<Status> enumHelper = new JacksonJsonEnumHelper<>(Status.class);
@JsonCreator
public static Status forValue(String value) {
return enumHelper.forValue(value);
}
@JsonValue
public String toValue() {
return (enumHelper.toString(this));
}
@Override
public String toString() {
return (enumHelper.toString(this));
}
}
private Integer id;
private String description;
private String name;
private String nameWithNamespace;
private String path;
private String pathWithNamespace;
private Date createdAt;
private Status exportStatus;
@JsonProperty("_links")
private Map<String, String> links;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameWithNamespace() {
return nameWithNamespace;
}
public void setNameWithNamespace(String nameWithNamespace) {
this.nameWithNamespace = nameWithNamespace;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getPathWithNamespace() {
return pathWithNamespace;
}
public void setPathWithNamespace(String pathWithNamespace) {
this.pathWithNamespace = pathWithNamespace;
}
public Date getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Date createdAt) {
this.createdAt = createdAt;
}
public Status getExportStatus() {
return exportStatus;
}
public void setExportStatus(Status exportStatus) {
this.exportStatus = exportStatus;
}
public Map<String, String> getLinks() {
return links;
}
public void setLinks(Map<String, String> links) {
this.links = links;
}
@JsonIgnore
public String getLinkByName(String name) {
if (links == null || links.isEmpty()) {
return (null);
}
return (links.get(name));
}
@Override
public String toString() {
return (JacksonJson.toJsonString(this));
}
}
package org.gitlab4j.api.models;
import java.util.Date;
import org.gitlab4j.api.utils.JacksonJson;
import org.gitlab4j.api.utils.JacksonJsonEnumHelper;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
public class ImportStatus {
/**
* Enum representing the status of the import.
*/
public enum Status {
NONE, SCHEDULED, FAILED, STARTED, FINISHED;
private static JacksonJsonEnumHelper<Status> enumHelper = new JacksonJsonEnumHelper<>(Status.class);
@JsonCreator
public static Status forValue(String value) {
return enumHelper.forValue(value);
}
@JsonValue
public String toValue() {
return (enumHelper.toString(this));
}
@Override
public String toString() {
return (enumHelper.toString(this));
}
}
private Integer id;
private String description;
private String name;
private String nameWithNamespace;
private String path;
private String pathWithNamespace;
private Date createdAt;
private Status importStatus;
private String importError;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameWithNamespace() {
return nameWithNamespace;
}
public void setNameWithNamespace(String nameWithNamespace) {
this.nameWithNamespace = nameWithNamespace;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getPathWithNamespace() {
return pathWithNamespace;
}
public void setPathWithNamespace(String pathWithNamespace) {
this.pathWithNamespace = pathWithNamespace;
}
public Date getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Date createdAt) {
this.createdAt = createdAt;
}
public Status getImportStatus() {
return importStatus;
}
public void setImportStatus(Status importStatus) {
this.importStatus = importStatus;
}
public String getImportError() {
return importError;
}
public void setImportError(String importError) {
this.importError = importError;
}
@Override
public String toString() {
return (JacksonJson.toJsonString(this));
}
}
......@@ -51,10 +51,12 @@ import org.gitlab4j.api.models.Email;
import org.gitlab4j.api.models.Epic;
import org.gitlab4j.api.models.EpicIssue;
import org.gitlab4j.api.models.Event;
import org.gitlab4j.api.models.ExportStatus;
import org.gitlab4j.api.models.FileUpload;
import org.gitlab4j.api.models.Group;
import org.gitlab4j.api.models.HealthCheckInfo;
import org.gitlab4j.api.models.ImpersonationToken;
import org.gitlab4j.api.models.ImportStatus;
import org.gitlab4j.api.models.Issue;
import org.gitlab4j.api.models.Job;
import org.gitlab4j.api.models.Key;
......@@ -192,6 +194,12 @@ public class TestGitLabApiBeans {
assertTrue(compareJson(event, "event.json"));
}
@Test
public void testExportStatus() throws Exception {
ExportStatus exportStatus = unmarshalResource(ExportStatus.class, "export-status.json");
assertTrue(compareJson(exportStatus, "export-status.json"));
}
@Test
public void testFileUpload() throws Exception {
FileUpload fileUpload = unmarshalResource(FileUpload.class, "file-upload.json");
......@@ -210,6 +218,12 @@ public class TestGitLabApiBeans {
assertTrue(compareJson(healthCheck, "health-check.json"));
}
@Test
public void testImportStatus() throws Exception {
ImportStatus importStatus = unmarshalResource(ImportStatus.class, "import-status.json");
assertTrue(compareJson(importStatus, "import-status.json"));
}
@Test
public void testIssue() throws Exception {
Issue issue = unmarshalResource(Issue.class, "issue.json");
......
/*
* The MIT License (MIT)
*
* Copyright (c) 2017 Greg Messner <greg@messners.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.gitlab4j.api;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeNotNull;
import java.io.File;
import java.util.Optional;
import org.gitlab4j.api.models.ExportStatus;
import org.gitlab4j.api.models.ImportStatus;
import org.gitlab4j.api.models.Project;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.junit.runners.MethodSorters;
/**
* In order for these tests to run you must set the following properties in ~/test-gitlab4j.properties
*
* TEST_NAMESPACE
* TEST_PROJECT_NAME
* TEST_HOST_URL
* TEST_PRIVATE_TOKEN
*
* If any of the above are NULL, all tests in this class will be skipped.
*/
@Category(IntegrationTest.class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TestImportExportApi extends AbstractIntegrationTest {
private static final String TEST_IMPORT_PROJECT_NAME = "test-import-project";
private static GitLabApi gitLabApi;
private static Project testProject;
public TestImportExportApi() {
super();
}
@BeforeClass
public static void setup() {
// Must setup the connection to the GitLab test server
gitLabApi = baseTestSetup();
testProject = getTestProject();
deleteAllTestProjects();
}
@AfterClass
public static void teardown() throws GitLabApiException {
deleteAllTestProjects();
}
private static void deleteAllTestProjects() {
if (gitLabApi == null) {
return;
}
try {
Project project = gitLabApi.getProjectApi().getProject(TEST_NAMESPACE, TEST_IMPORT_PROJECT_NAME);
gitLabApi.getProjectApi().deleteProject(project);
} catch (GitLabApiException ignore) {}
}
@Before
public void beforeMethod() {
assumeNotNull(gitLabApi);
}
@Test
public void testScheduleExport() throws GitLabApiException {
// Arrange
assumeNotNull(testProject);
// Act
gitLabApi.getImportExportApi().scheduleExport(testProject);
ExportStatus exportStatus = gitLabApi.getImportExportApi().getExportStatus(testProject);
// Assert
assertNotNull(exportStatus);
ExportStatus.Status status = exportStatus.getExportStatus();
assertNotEquals(ExportStatus.Status.NONE, status);
}
@Test
public void testExportDownloadAndImport() throws GitLabApiException {
// Arrange
assumeNotNull(testProject);
// Act
gitLabApi.getImportExportApi().scheduleExport(testProject);
// Wait up to 20 seconds for the export to complete
System.out.print("Waiting for export to complete");
int retries = 0;
while (true) {
System.out.print(".");
ExportStatus exportStatus = gitLabApi.getImportExportApi().getExportStatus(testProject);
if (exportStatus.getExportStatus() == ExportStatus.Status.FINISHED) {
System.out.println("done");
break;
}
if (retries >= 20) {
System.out.println("aborting!");
fail("Project export is taking too long, failing test.");
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
retries++;
}
File exportDownload = null;
try {
System.out.println("Downloading exported project");
exportDownload = gitLabApi.getImportExportApi().downloadExport(testProject, null);
assertNotNull(exportDownload);
assertTrue(exportDownload.length() > 10000);
ImportStatus importStatus = gitLabApi.getImportExportApi().startImport(null, exportDownload,
TEST_IMPORT_PROJECT_NAME, true, null);
assertNotNull(importStatus);
Integer newProjectId = importStatus.getId();
// Wait up to 20 seconds for the import to complete
System.out.print("Waiting for import to complete");
retries = 0;
while (true) {
System.out.print(".");
importStatus = gitLabApi.getImportExportApi().getImportStatus(newProjectId);
if (importStatus.getImportStatus() == ImportStatus.Status.FINISHED) {
System.out.println("done");
break;
}
if (retries >= 20) {
System.out.println("aborting!");
fail("Project import is taking too long, failing test.");
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
retries++;
}
Optional<Project> newProject = gitLabApi.getProjectApi().getOptionalProject(newProjectId);
assertTrue(newProject.isPresent());
assertEquals(newProjectId, newProject.get().getId());
assertEquals(TEST_IMPORT_PROJECT_NAME, newProject.get().getName());
} finally {
if (exportDownload != null) {
exportDownload.delete();
}
}
}
}
{
"id": 1,
"description": "Itaque perspiciatis minima aspernatur corporis consequatur.",
"name": "Gitlab Test",
"name_with_namespace": "Gitlab Org / Gitlab Test",
"path": "gitlab-test",
"path_with_namespace": "gitlab-org/gitlab-test",
"created_at": "2017-08-29T04:36:44.383Z",
"export_status": "finished",
"_links": {
"api_url": "https://gitlab.example.com/api/v4/projects/1/export/download",
"web_url": "https://gitlab.example.com/gitlab-org/gitlab-test/download_export"
}
}
{
"id": 1,
"name": "api-project",
"name_with_namespace": "Administrator / api-project",
"path": "api-project",
"path_with_namespace": "root/api-project",
"created_at": "2018-02-13T09:05:58.023Z",
"import_status": "scheduled"
}
\ No newline at end of file
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment