Improve repository information page (#1636)

Only show relevant information for repository on repository information page. The initialization code example is only shown if the repository is still empty.
This commit is contained in:
Eduard Heimbuch
2021-04-29 18:13:32 +02:00
committed by GitHub
parent 32b268e6f5
commit af8980de19
13 changed files with 442 additions and 116 deletions

View File

@@ -0,0 +1,2 @@
- type: changed
description: Show only relevant information on repository information page ([#1636](https://github.com/scm-manager/scm-manager/pull/1636))

View File

@@ -24,16 +24,19 @@
package sonia.scm.api.v2.resources;
import com.google.common.base.Strings;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitRepositoryConfig;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager;
@@ -61,12 +64,14 @@ public class GitRepositoryConfigResource {
private final GitRepositoryConfigMapper repositoryConfigMapper;
private final RepositoryManager repositoryManager;
private final GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider;
private final GitRepositoryHandler repositoryHandler;
@Inject
public GitRepositoryConfigResource(GitRepositoryConfigMapper repositoryConfigMapper, RepositoryManager repositoryManager, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) {
public GitRepositoryConfigResource(GitRepositoryConfigMapper repositoryConfigMapper, RepositoryManager repositoryManager, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider, GitRepositoryHandler repositoryHandler) {
this.repositoryConfigMapper = repositoryConfigMapper;
this.repositoryManager = repositoryManager;
this.gitRepositoryConfigStoreProvider = gitRepositoryConfigStoreProvider;
this.repositoryHandler = repositoryHandler;
}
@GET
@@ -100,12 +105,55 @@ public class GitRepositoryConfigResource {
public Response getRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name) {
Repository repository = getRepository(namespace, name);
RepositoryPermissions.read(repository).check();
ConfigurationStore<GitRepositoryConfig> repositoryConfigStore = getStore(repository);
GitRepositoryConfig config = repositoryConfigStore.get();
GitRepositoryConfig config = getStore(repository).get();
GitRepositoryConfigDto dto = repositoryConfigMapper.map(config, repository);
return Response.ok(dto).build();
}
@GET
@Path("default-branch")
@Produces(GitVndMediaType.GIT_REPOSITORY_DEFAULT_BRANCH)
@Operation(summary = "Git repository default branch", description = "Returns the default branch for the repository.", tags = "Git")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = GitVndMediaType.GIT_REPOSITORY_CONFIG,
schema = @Schema(implementation = GitRepositoryConfigDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the repository config")
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified namespace and name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getDefaultBranch(@PathParam("namespace") String namespace, @PathParam("name") String name) {
Repository repository = getRepository(namespace, name);
RepositoryPermissions.read(repository).check();
GitRepositoryConfig config = getStore(repository).get();
String defaultBranch = "main";
if (!Strings.isNullOrEmpty(config.getDefaultBranch())) {
defaultBranch = config.getDefaultBranch();
} else if (!Strings.isNullOrEmpty(repositoryHandler.getConfig().getDefaultBranch())) {
defaultBranch = repositoryHandler.getConfig().getDefaultBranch();
}
return Response.ok(new DefaultBranchDto(defaultBranch)).build();
}
@PUT
@Path("/")
@Consumes(GitVndMediaType.GIT_REPOSITORY_CONFIG)
@@ -167,4 +215,10 @@ public class GitRepositoryConfigResource {
private ConfigurationStore<GitRepositoryConfig> getStore(Repository repository) {
return gitRepositoryConfigStoreProvider.get(repository);
}
@Getter
@AllArgsConstructor
public static class DefaultBranchDto {
private final String defaultBranch;
}
}

View File

@@ -0,0 +1,62 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* 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 sonia.scm.api.v2.resources;
import sonia.scm.plugin.Extension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import javax.inject.Inject;
import javax.inject.Provider;
@Extension
@Enrich(Repository.class)
public class RepositoryLinkEnricher implements HalEnricher {
private final Provider<ScmPathInfoStore> scmPathInfoStore;
@Inject
public RepositoryLinkEnricher(Provider<ScmPathInfoStore> scmPathInfoStore) {
this.scmPathInfoStore = scmPathInfoStore;
}
@Override
public void enrich(HalEnricherContext context, HalAppender appender) {
Repository repository = context.oneRequireByType(Repository.class);
LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStore.get().get(), GitConfigResource.class, GitRepositoryConfigResource.class);
if (RepositoryPermissions.read(repository).isPermitted()) {
appender.appendLink("defaultBranch", getDefaultBranchLink(repository, linkBuilder));
}
}
private String getDefaultBranchLink(Repository repository, LinkBuilder linkBuilder) {
return linkBuilder
.method("getRepositoryConfig").parameters(repository.getNamespace(), repository.getName())
.method("getDefaultBranch").parameters()
.href();
}
}

View File

@@ -27,6 +27,7 @@ package sonia.scm.web;
public class GitVndMediaType {
public static final String GIT_CONFIG = VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX;
public static final String GIT_REPOSITORY_CONFIG = VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX;
public static final String GIT_REPOSITORY_DEFAULT_BRANCH = VndMediaType.PREFIX + "gitDefaultBranch" + VndMediaType.SUFFIX;
private GitVndMediaType() {
}

View File

@@ -21,25 +21,60 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, Repository } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
type Props = WithTranslation & {
type Props = {
url: string;
repository: Repository;
};
class CloneInformation extends React.Component<Props> {
render() {
const { url, repository, t } = this.props;
const CloneInformation: FC<Props> = ({ url, repository }) => {
const [t] = useTranslation("plugins");
const [defaultBranch, setDefaultBranch] = useState<string>("main");
const [emptyRepository, setEmptyRepository] = useState<boolean>();
useEffect(() => {
if (repository) {
apiClient
.get((repository._links.changesets as Link).href + "?limit=1")
.then(r => r.json())
.then(result => {
const empty = result._embedded.changesets.length === 0;
setEmptyRepository(empty);
});
}
}, [repository]);
useEffect(() => {
if (repository) {
apiClient
.get((repository._links.defaultBranch as Link).href)
.then(r => r.json())
.then(r => r.defaultBranch && setDefaultBranch(r.defaultBranch));
}
}, [repository]);
return (
<div>
<h4>{t("scm-git-plugin.information.clone")}</h4>
<pre>
<code>git clone {url}</code>
<code>
git clone {url}
<br />
cd {repository.name}
{emptyRepository && (
<>
<br />
git checkout -b {defaultBranch}
</>
)}
</code>
</pre>
{emptyRepository && (
<>
<h4>{t("scm-git-plugin.information.create")}</h4>
<pre>
<code>
@@ -47,6 +82,8 @@ class CloneInformation extends React.Component<Props> {
<br />
cd {repository.name}
<br />
git checkout -b {defaultBranch}
<br />
echo "# {repository.name}
" &gt; README.md
<br />
@@ -56,22 +93,21 @@ class CloneInformation extends React.Component<Props> {
<br />
git remote add origin {url}
<br />
git push -u origin master
git push -u origin {defaultBranch}
<br />
</code>
</pre>
</>
)}
<h4>{t("scm-git-plugin.information.replace")}</h4>
<pre>
<code>
git remote add origin {url}
<br />
git push -u origin master
<br />
</code>
</pre>
</div>
);
}
}
};
export default withTranslation("plugins")(CloneInformation);
export default CloneInformation;

View File

@@ -22,7 +22,6 @@
* SOFTWARE.
*/
import React from "react";
import { binder } from "@scm-manager/ui-extensions";
import ProtocolInformation from "./ProtocolInformation";
import GitAvatar from "./GitAvatar";

View File

@@ -3,7 +3,7 @@
"information": {
"clone": "Repository klonen",
"create": "Neues Repository erstellen",
"replace": "Ein bestehendes Repository aktualisieren",
"replace": "Quelle zum bestehenden Repository hinzufügen",
"fetch": "Remote-Änderungen herunterladen",
"checkout": "Branch wechseln",
"checkoutTag": "Tag als neuen Branch auschecken",

View File

@@ -3,7 +3,7 @@
"information": {
"clone": "Clone the Repository",
"create": "Create a New Repository",
"replace": "Push an Existing Repository",
"replace": "Add Remote Origin to an Existing Repository",
"fetch": "Get Remote Changes",
"checkout": "Switch Branch",
"checkoutTag": "Checkout Tag as New Branch",

View File

@@ -74,7 +74,7 @@ public class GitConfigResourceTest {
@Rule
public ShiroRule shiro = new ShiroRule();
private RestDispatcher dispatcher = new RestDispatcher();
private final RestDispatcher dispatcher = new RestDispatcher();
private final URI baseUri = URI.create("/");
@@ -106,7 +106,7 @@ public class GitConfigResourceTest {
public void prepareEnvironment() {
GitConfig gitConfig = createConfiguration();
when(repositoryHandler.getConfig()).thenReturn(gitConfig);
GitRepositoryConfigResource gitRepositoryConfigResource = new GitRepositoryConfigResource(repositoryConfigMapper, repositoryManager, new GitRepositoryConfigStoreProvider(configurationStoreFactory));
GitRepositoryConfigResource gitRepositoryConfigResource = new GitRepositoryConfigResource(repositoryConfigMapper, repositoryManager, new GitRepositoryConfigStoreProvider(configurationStoreFactory), repositoryHandler);
GitConfigResource gitConfigResource = new GitConfigResource(dtoToConfigMapper, configToDtoMapper, repositoryHandler, of(gitRepositoryConfigResource));
dispatcher.addSingletonResource(gitConfigResource);
when(scmPathInfoStore.get().getApiRestUri()).thenReturn(baseUri);
@@ -251,6 +251,71 @@ public class GitConfigResourceTest {
.isEqualTo("new");
}
@Test
@SubjectAware(username = "readOnly")
public void shouldGetDefaultBranchFromRepoConfig() throws URISyntaxException, UnsupportedEncodingException {
when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X"));
when(configurationStore.get()).thenReturn(new GitRepositoryConfig("default"));
MockHttpRequest request = MockHttpRequest
.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X/default-branch")
.contentType(GitVndMediaType.GIT_REPOSITORY_CONFIG);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertEquals("{\"defaultBranch\":\"default\"}", response.getContentAsString());
}
@Test
@SubjectAware(username = "readOnly")
public void shouldGetDefaultBranchFromGlobalConfig() throws URISyntaxException, UnsupportedEncodingException {
when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X"));
when(configurationStore.get()).thenReturn(new GitRepositoryConfig());
GitConfig globalGitConfig = createConfiguration();
globalGitConfig.setDefaultBranch("global-default");
when(repositoryHandler.getConfig()).thenReturn(globalGitConfig);
MockHttpRequest request = MockHttpRequest
.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X/default-branch")
.contentType(GitVndMediaType.GIT_REPOSITORY_CONFIG);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertEquals("{\"defaultBranch\":\"global-default\"}", response.getContentAsString());
}
@Test
@SubjectAware(username = "readOnly")
public void shouldGetFallbackDefaultBranchIfBothConfigsEmpty() throws URISyntaxException, UnsupportedEncodingException {
when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X"));
when(configurationStore.get()).thenReturn(new GitRepositoryConfig());
when(repositoryHandler.getConfig()).thenReturn(createConfiguration());
MockHttpRequest request = MockHttpRequest
.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X/default-branch")
.contentType(GitVndMediaType.GIT_REPOSITORY_CONFIG);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertEquals("{\"defaultBranch\":\"main\"}", response.getContentAsString());
}
@Test
public void shouldThrowAuthorizationExceptionIfNotPermittedToGetDefaultBranch() throws URISyntaxException {
when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X"));
MockHttpRequest request = MockHttpRequest
.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X/default-branch")
.contentType(GitVndMediaType.GIT_REPOSITORY_CONFIG);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
}
private MockHttpResponse get() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2);
MockHttpResponse response = new MockHttpResponse();

View File

@@ -0,0 +1,90 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* 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 sonia.scm.api.v2.resources;
import com.google.inject.util.Providers;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import javax.inject.Provider;
import java.net.URI;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ExtendWith(ShiroExtension.class)
@ExtendWith(MockitoExtension.class)
@SubjectAware(
value = "trillian"
)
class RepositoryLinkEnricherTest {
private static final Repository REPOSITORY = RepositoryTestData.create42Puzzle();
private RepositoryLinkEnricher repositoryLinkEnricher;
@Mock
private HalAppender appender;
@BeforeEach
void initEnricher() {
ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore();
scmPathInfoStore.set(() -> URI.create("/api/"));
Provider<ScmPathInfoStore> scmPathInfoStoreProvider = Providers.of(scmPathInfoStore);
repositoryLinkEnricher = new RepositoryLinkEnricher(scmPathInfoStoreProvider);
REPOSITORY.setId("id-1");
}
@Test
void shouldNotAppendLinkIfNotPermitted() {
HalEnricherContext context = HalEnricherContext.of(REPOSITORY);
repositoryLinkEnricher.enrich(context, appender);
verify(appender, never()).appendLink(eq("defaultBranch"), any(String.class));
}
@Test
@SubjectAware(
permissions = "repository:read:id-1"
)
void shouldAppendDefaultBranchLink() {
HalEnricherContext context = HalEnricherContext.of(REPOSITORY);
repositoryLinkEnricher.enrich(context, appender);
verify(appender).appendLink("defaultBranch", "/api/v2/config/git/hitchhiker/42Puzzle/default-branch");
}
}

View File

@@ -21,19 +21,33 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { repositories } from "@scm-manager/ui-components";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, Repository } from "@scm-manager/ui-types";
import { apiClient, repositories } from "@scm-manager/ui-components";
type Props = WithTranslation & {
type Props = {
repository: Repository;
};
class ProtocolInformation extends React.Component<Props> {
render() {
const { repository, t } = this.props;
const ProtocolInformation: FC<Props> = ({ repository }) => {
const [t] = useTranslation("plugins");
const [emptyRepository, setEmptyRepository] = useState<boolean>();
const href = repositories.getProtocolLinkByType(repository, "http");
useEffect(() => {
if (repository) {
apiClient
.get((repository._links.changesets as Link).href)
.then(r => r.json())
.then(result => {
const empty = result._embedded.changesets.length === 0;
setEmptyRepository(empty);
});
}
}, [repository]);
if (!href) {
return null;
}
@@ -43,6 +57,8 @@ class ProtocolInformation extends React.Component<Props> {
<pre>
<code>hg clone {href}</code>
</pre>
{emptyRepository && (
<>
<h4>{t("scm-hg-plugin.information.create")}</h4>
<pre>
<code>
@@ -67,6 +83,8 @@ class ProtocolInformation extends React.Component<Props> {
<br />
</code>
</pre>
</>
)}
<h4>{t("scm-hg-plugin.information.replace")}</h4>
<pre>
<code>
@@ -81,7 +99,6 @@ class ProtocolInformation extends React.Component<Props> {
</pre>
</div>
);
}
}
};
export default withTranslation("plugins")(ProtocolInformation);
export default ProtocolInformation;

View File

@@ -3,7 +3,7 @@
"information": {
"clone" : "Repository klonen",
"create" : "Neues Repository erstellen",
"replace" : "Ein bestehendes Repository aktualisieren",
"replace" : "Quelle zum bestehenden Repository hinzufügen",
"fetch": "Remote-Änderungen herunterladen",
"checkout": "Branch wechseln",
"checkoutTag": "Tag auschecken"

View File

@@ -3,7 +3,7 @@
"information": {
"clone" : "Clone the repository",
"create" : "Create a New Repository",
"replace" : "Push an Existing Repository",
"replace" : "Add Remote Origin to an Existing Repository",
"fetch": "Get Remote Changes",
"checkout": "Switch Branch",
"checkoutTag": "Checkout Tag"