diff --git a/scm-ui/ui-types/src/Branches.ts b/scm-ui/ui-types/src/Branches.ts index 87eb5af3dc..de8aa5fa49 100644 --- a/scm-ui/ui-types/src/Branches.ts +++ b/scm-ui/ui-types/src/Branches.ts @@ -23,11 +23,14 @@ */ import { Links } from "./hal"; +import { Person } from "."; export type Branch = { name: string; revision: string; defaultBranch?: boolean; + lastModified?: Date; + lastModifier?: Person; _links: Links; }; diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 68e28e5d35..5b3e74f701 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -51,7 +51,9 @@ "overview": { "title": "Übersicht aller verfügbaren Branches", "noBranches": "Keine Branches gefunden.", - "createButton": "Branch erstellen" + "createButton": "Branch erstellen", + "lastModifier": "von", + "lastModified": "Aktualisiert" }, "table": { "branches": "Branches" diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index d6e003a499..eeab9a9dda 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -51,7 +51,9 @@ "overview": { "title": "Overview of all branches", "noBranches": "No branches found.", - "createButton": "Create Branch" + "createButton": "Create Branch", + "lastModifier": "by", + "lastModified": "Updated" }, "table": { "branches": "Branches" diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx index 866ac8d849..58bae4ceb5 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx @@ -21,34 +21,42 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; +import React, { FC } from "react"; import { Link } from "react-router-dom"; import { Branch } from "@scm-manager/ui-types"; import DefaultBranchTag from "./DefaultBranchTag"; +import { DateFromNow } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; type Props = { baseUrl: string; branch: Branch; }; -class BranchRow extends React.Component { - renderLink(to: string, label: string, defaultBranch?: boolean) { - return ( - - {label} - - ); - } +const Modified = styled.span` + margin-left: 1rem; + font-size: 0.8rem; +`; - render() { - const { baseUrl, branch } = this.props; - const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`; - return ( - - {this.renderLink(to, branch.name, branch.defaultBranch)} - - ); - } -} +const BranchRow: FC = ({ baseUrl, branch }) => { + const [t] = useTranslation("repos"); + + const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`; + return ( + + + + {branch.name} + + + {t("branches.overview.lastModified")} {" "} + {t("branches.overview.lastModifier")} {branch.lastModifier?.name} + + + + + ); +}; export default BranchRow; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java index c7c1a4a917..5e52d57152 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java @@ -21,9 +21,10 @@ * 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; @@ -31,20 +32,29 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.validator.constraints.Length; + import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; +import java.time.Instant; -@Getter @Setter @NoArgsConstructor +@Getter +@Setter +@NoArgsConstructor public class BranchDto extends HalRepresentation { private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>"; private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/."; static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?"; - @NotEmpty @Length(min = 1, max=100) @Pattern(regexp = VALID_BRANCH_NAMES) + @NotEmpty + @Length(min = 1, max = 100) + @Pattern(regexp = VALID_BRANCH_NAMES) private String name; private String revision; private boolean defaultBranch; + @JsonInclude(JsonInclude.Include.NON_NULL) + private Instant lastModified; + private PersonDto lastModifier; BranchDto(Links links, Embedded embedded) { super(links, embedded); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java index 0d779d2164..e49a42d321 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java @@ -21,7 +21,7 @@ * 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; @@ -30,11 +30,19 @@ import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ObjectFactory; +import sonia.scm.ContextEntry; import sonia.scm.repository.Branch; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Person; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.web.EdisonHalAppender; import javax.inject.Inject; +import java.io.IOException; +import java.time.Instant; import static de.otto.edison.hal.Link.linkBuilder; import static de.otto.edison.hal.Links.linkingTo; @@ -45,9 +53,14 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper { @Inject private ResourceLinks resourceLinks; + @Inject + private RepositoryServiceFactory serviceFactory; + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract BranchDto map(Branch branch, @Context NamespaceAndName namespaceAndName); + abstract PersonDto map(Person person); + @ObjectFactory BranchDto createDto(@Context NamespaceAndName namespaceAndName, Branch branch) { Links.Builder linksBuilder = linkingTo() @@ -58,7 +71,20 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper { Embedded.Builder embeddedBuilder = Embedded.embeddedBuilder(); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), branch, namespaceAndName); + BranchDto branchDto = new BranchDto(linksBuilder.build(), embeddedBuilder.build()); - return new BranchDto(linksBuilder.build(), embeddedBuilder.build()); + try (RepositoryService service = serviceFactory.create(namespaceAndName)) { + Changeset latestChangeset = service.getLogCommand().setBranch(branch.getName()).getChangesets().getChangesets().get(0); + branchDto.setLastModified(Instant.ofEpochMilli(latestChangeset.getDate())); + branchDto.setLastModifier(map(latestChangeset.getAuthor())); + } catch (IOException e) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Branch.class, branch.getName()), + "Could not read latest changeset for branch", + e + ); + } + + return branchDto; } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java index c9b9ea769f..1a64c46110 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java @@ -21,19 +21,32 @@ * 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.collect.ImmutableList; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.Branch; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.PersonTestData; +import sonia.scm.repository.api.LogCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import java.io.IOException; import java.net.URI; +import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BranchToBranchDtoMapperTest { @@ -43,6 +56,13 @@ class BranchToBranchDtoMapperTest { @SuppressWarnings("unused") // Is injected private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService repositoryService; + @Mock(answer = Answers.RETURNS_SELF) + private LogCommandBuilder logCommandBuilder; + @InjectMocks private BranchToBranchDtoMapperImpl mapper; @@ -63,4 +83,21 @@ class BranchToBranchDtoMapperTest { assertThat(dto.getLinks().getLinkBy("ka").get().getHref()).isEqualTo("http://hitchhiker/heart-of-gold/master"); } + @Test + void shouldMapLastChangeDateAndLastModifier() throws IOException { + long creationTime = 1000000000; + Changeset changeset = new Changeset("1", 1L, PersonTestData.ZAPHOD); + changeset.setDate(creationTime); + + when(serviceFactory.create(any(NamespaceAndName.class))).thenReturn(repositoryService); + when(repositoryService.getLogCommand()).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(new ChangesetPagingResult(1, ImmutableList.of(changeset))); + Branch branch = Branch.normalBranch("master", "42"); + + BranchDto dto = mapper.map(branch, new NamespaceAndName("hitchhiker", "heart-of-gold")); + + assertThat(dto.getLastModified()).isEqualTo(Instant.ofEpochMilli(creationTime)); + assertThat(dto.getLastModifier().getName()).isEqualTo(PersonTestData.ZAPHOD.getName()); + } + }