Feature/branch details (#1876)

Enrich branch overview with more details like last committer and ahead/behind commits. Since calculating this information is pretty intense, we request it in chunks to prevent very long loading times. Also we cache the results in frontend and backend.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-12-01 14:19:18 +01:00
committed by GitHub
parent ce2eae1843
commit 9cc134f5a8
59 changed files with 1933 additions and 154 deletions

View File

@@ -0,0 +1,54 @@
/*
* 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.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
public class BranchDetailsDto extends HalRepresentation {
private String branchName;
@JsonInclude(NON_NULL)
private Integer changesetsAhead;
@JsonInclude(NON_NULL)
private Integer changesetsBehind;
BranchDetailsDto(Links links, Embedded embedded) {
super(links, embedded);
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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.common.annotations.VisibleForTesting;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.BranchDetailsCommandResult;
import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import java.util.Optional;
@Mapper
public abstract class BranchDetailsMapper extends BaseMapper<BranchDetailsCommandResult, BranchDetailsDto> {
@Inject
private ResourceLinks resourceLinks;
abstract BranchDetailsDto map(@Context Repository repository, String branchName, BranchDetailsCommandResult result);
@ObjectFactory
BranchDetailsDto createDto(@Context Repository repository, String branchName) {
Links.Builder linksBuilder = createLinks(repository, branchName);
Embedded.Builder embeddedBuilder = Embedded.embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository);
return new BranchDetailsDto(linksBuilder.build(), embeddedBuilder.build());
}
Integer map(Optional<Integer> o) {
return o.orElse(null);
}
private Links.Builder createLinks(@Context Repository repository, String branch) {
return Links.linkingTo()
.self(
resourceLinks.branchDetails()
.self(
repository.getNamespace(),
repository.getName(),
branch)
);
}
@VisibleForTesting
void setResourceLinks(ResourceLinks resourceLinks) {
this.resourceLinks = resourceLinks;
}
}

View File

@@ -0,0 +1,201 @@
/*
* 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 de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.hibernate.validator.constraints.Length;
import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.BranchDetailsCommandBuilder;
import sonia.scm.repository.api.BranchDetailsCommandResult;
import sonia.scm.repository.api.CommandNotSupportedException;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.validation.constraints.Pattern;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES;
@Path("")
public class BranchDetailsResource {
private final RepositoryServiceFactory serviceFactory;
private final BranchDetailsMapper mapper;
private final ResourceLinks resourceLinks;
@Inject
public BranchDetailsResource(RepositoryServiceFactory serviceFactory, BranchDetailsMapper mapper, ResourceLinks resourceLinks) {
this.serviceFactory = serviceFactory;
this.mapper = mapper;
this.resourceLinks = resourceLinks;
}
/**
* Returns branch details for given branch.
*
* <strong>Note:</strong> This method requires "repository" privilege.
*
* @param namespace the namespace of the repository
* @param name the name of the repository
* @param branchName the name of the branch
*/
@GET
@Path("{branch}")
@Produces(VndMediaType.BRANCH_DETAILS)
@Operation(summary = "Get single branch details", description = "Returns details of a single branch.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.BRANCH_DETAILS,
schema = @Schema(implementation = BranchDetailsDto.class)
)
)
@ApiResponse(responseCode = "400", description = "branches not supported for given repository")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the branch")
@ApiResponse(
responseCode = "404",
description = "not found, no branch with the specified name for the repository available or repository found",
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 getBranchDetails(
@PathParam("namespace") String namespace,
@PathParam("name") String name,
@Length(min = 1, max = 100) @Pattern(regexp = VALID_BRANCH_NAMES) @PathParam("branch") String branchName
) {
try (RepositoryService service = serviceFactory.create(new NamespaceAndName(namespace, name))) {
BranchDetailsCommandResult result = service.getBranchDetailsCommand().execute(branchName);
BranchDetailsDto dto = mapper.map(service.getRepository(), branchName, result);
return Response.ok(dto).build();
} catch (CommandNotSupportedException ex) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
/**
* Returns branch details for given branches.
*
* <strong>Note:</strong> This method requires "repository" privilege.
*
* @param namespace the namespace of the repository
* @param name the name of the repository
* @param branches a comma-seperated list of branches
*/
@GET
@Path("")
@Produces(VndMediaType.BRANCH_DETAILS_COLLECTION)
@Operation(summary = "Get multiple branch details", description = "Returns a collection of branch details.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.BRANCH_DETAILS_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "400", description = "branches not supported for given repository")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the branch")
@ApiResponse(
responseCode = "404",
description = "not found, no branch with the specified name for the repository available or repository found",
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 getBranchDetailsCollection(
@PathParam("namespace") String namespace,
@PathParam("name") String name,
@QueryParam("branches") List<@Length(min = 1, max = 100) @Pattern(regexp = VALID_BRANCH_NAMES) String> branches
) {
try (RepositoryService service = serviceFactory.create(new NamespaceAndName(namespace, name))) {
List<BranchDetailsDto> dtos = getBranchDetailsDtos(service, decodeBranchNames(branches));
Links links = Links.linkingTo().self(resourceLinks.branchDetailsCollection().self(namespace, name)).build();
Embedded embedded = Embedded.embeddedBuilder().with("branchDetails", dtos).build();
return Response.ok(new HalRepresentation(links, embedded)).build();
} catch (CommandNotSupportedException ex) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
private List<BranchDetailsDto> getBranchDetailsDtos(RepositoryService service, Collection<String> branches) {
List<BranchDetailsDto> dtos = new ArrayList<>();
if (!branches.isEmpty()) {
BranchDetailsCommandBuilder branchDetailsCommand = service.getBranchDetailsCommand();
for (String branch : branches) {
try {
BranchDetailsCommandResult result = branchDetailsCommand.execute(branch);
dtos.add(mapper.map(service.getRepository(), branch, result));
} catch (NotFoundException e) {
// we simply omit details for branches that do not exist
}
}
}
return dtos;
}
private Collection<String> decodeBranchNames(Collection<String> branches) {
return branches.stream().map(HttpUtil::decode).collect(Collectors.toList());
}
}

View File

@@ -55,6 +55,7 @@ public class BranchDto extends HalRepresentation {
private boolean defaultBranch;
@JsonInclude(NON_NULL)
private Instant lastCommitDate;
private PersonDto lastCommitter;
private boolean stale;
BranchDto(Links links, Embedded embedded) {

View File

@@ -32,6 +32,7 @@ import org.mapstruct.Mapping;
import org.mapstruct.ObjectFactory;
import sonia.scm.repository.Branch;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Person;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.web.EdisonHalAppender;
@@ -53,6 +54,8 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper implemen
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
public abstract BranchDto map(Branch branch, @Context Repository repository);
abstract PersonDto map(Person person);
@ObjectFactory
BranchDto createDto(@Context Repository repository, Branch branch) {
NamespaceAndName namespaceAndName = new NamespaceAndName(repository.getNamespace(), repository.getName());

View File

@@ -30,6 +30,7 @@ import javax.inject.Provider;
public class RepositoryBasedResourceProvider {
private final Provider<TagRootResource> tagRootResource;
private final Provider<BranchRootResource> branchRootResource;
private final Provider<BranchDetailsResource> branchDetailsResource;
private final Provider<ChangesetRootResource> changesetRootResource;
private final Provider<SourceRootResource> sourceRootResource;
private final Provider<ContentResource> contentResource;
@@ -46,6 +47,7 @@ public class RepositoryBasedResourceProvider {
public RepositoryBasedResourceProvider(
Provider<TagRootResource> tagRootResource,
Provider<BranchRootResource> branchRootResource,
Provider<BranchDetailsResource> branchDetailsResource,
Provider<ChangesetRootResource> changesetRootResource,
Provider<SourceRootResource> sourceRootResource,
Provider<ContentResource> contentResource,
@@ -59,6 +61,7 @@ public class RepositoryBasedResourceProvider {
Provider<RepositoryPathsResource> repositoryPathResource) {
this.tagRootResource = tagRootResource;
this.branchRootResource = branchRootResource;
this.branchDetailsResource = branchDetailsResource;
this.changesetRootResource = changesetRootResource;
this.sourceRootResource = sourceRootResource;
this.contentResource = contentResource;
@@ -123,4 +126,8 @@ public class RepositoryBasedResourceProvider {
public RepositoryPathsResource getRepositoryPathResource() {
return repositoryPathResource.get();
}
public BranchDetailsResource getBranchDetailsResource() {
return branchDetailsResource.get();
}
}

View File

@@ -248,6 +248,7 @@ public class RepositoryResource {
Repository repository = loadBy(namespace, name).get();
manager.archive(repository);
}
/**
* Marks the given repository as not "archived".
*
@@ -314,6 +315,11 @@ public class RepositoryResource {
return resourceProvider.getBranchRootResource();
}
@Path("branch-details/")
public BranchDetailsResource branchDetails() {
return resourceProvider.getBranchDetailsResource();
}
@Path("changesets/")
public ChangesetRootResource changesets() {
return resourceProvider.getChangesetRootResource();

View File

@@ -148,6 +148,9 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
if (repositoryService.isSupported(Command.BRANCHES)) {
linksBuilder.single(link("branches", resourceLinks.branchCollection().self(repository.getNamespace(), repository.getName())));
}
if (repositoryService.isSupported(Command.BRANCH_DETAILS)) {
linksBuilder.single(link("branchDetailsCollection", resourceLinks.branchDetailsCollection().self(repository.getNamespace(), repository.getName())));
}
if (repositoryService.isSupported(Feature.INCOMING_REVISION)) {
linksBuilder.single(link("incomingChangesets", resourceLinks.incoming().changesets(repository.getNamespace(), repository.getName())));
linksBuilder.single(link("incomingDiff", resourceLinks.incoming().diff(repository.getNamespace(), repository.getName())));

View File

@@ -31,7 +31,7 @@ import java.net.URI;
import java.net.URISyntaxException;
@SuppressWarnings("squid:S1192")
// string literals should not be duplicated
// string literals should not be duplicated
class ResourceLinks {
private final ScmPathInfoStore scmPathInfoStore;
@@ -576,6 +576,38 @@ class ResourceLinks {
}
}
public BranchDetailsLinks branchDetails() {
return new BranchDetailsLinks(scmPathInfoStore.get());
}
static class BranchDetailsLinks {
private final LinkBuilder branchDetailsLinkBuilder;
BranchDetailsLinks(ScmPathInfo pathInfo) {
branchDetailsLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, BranchDetailsResource.class);
}
String self(String namespace, String name, String branch) {
return branchDetailsLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branchDetails").parameters().method("getBranchDetails").parameters(branch).href();
}
}
public BranchDetailsCollectionLinks branchDetailsCollection() {
return new BranchDetailsCollectionLinks(scmPathInfoStore.get());
}
static class BranchDetailsCollectionLinks {
private final LinkBuilder branchDetailsLinkBuilder;
BranchDetailsCollectionLinks(ScmPathInfo pathInfo) {
branchDetailsLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, BranchDetailsResource.class);
}
String self(String namespace, String name) {
return branchDetailsLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branchDetails").parameters().method("getBranchDetailsCollection").parameters().href();
}
}
public IncomingLinks incoming() {
return new IncomingLinks(scmPathInfoStore.get());
}