Merge pull request #1439 from scm-manager/feature/commit_date_for_branch

Feature "Commit Date for Branch"
This commit is contained in:
René Pfeuffer
2020-11-26 09:37:23 +01:00
committed by GitHub
28 changed files with 567 additions and 228 deletions

View File

@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Add tooltips to short links on repository overview ([#1441](https://github.com/scm-manager/scm-manager/pull/1441))
- Show the date of the last commit for branches in the frontend ([#1439](https://github.com/scm-manager/scm-manager/pull/1439))
## [2.10.1] - 2020-11-24
### Fixed

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 195 KiB

View File

@@ -3,9 +3,11 @@ title: Repository
subtitle: Branches
---
### Übersicht
Auf der Branches-Übersicht sind die bereits existierenden Branches aufgeführt. Bei einem Klick auf einen Branch wird man zur Detailseite des Branches weitergeleitet.
Auf der Branches-Übersicht sind die bereits existierenden Branches aufgeführt. Bei einem Klick auf einen Branch wird man zur Detailseite des Branches weitergeleitet.
Die Branches sind in zwei Listen aufgeteilt: Unter "Aktive Branches" sind Branches aufgelistet, deren letzter Commit
nicht 30 Tage älter als der Stand des Default-Branches ist. Alle älteren Branches sind in der Liste "Stale Branches" zu finden.
Der Tag "Default" gibt an welcher Branch aktuell, als Standard-Branch dieses Repository im SCM-Manager markiert ist. Der Standard-Branch wird immer zuerst angezeigt, wenn man das Repository im SCM-Manager öffnet.
Der Tag "Default" gibt an, welcher Branch aktuell als Standard-Branch dieses Repository im SCM-Manager markiert ist. Der Standard-Branch wird immer zuerst angezeigt, wenn man das Repository im SCM-Manager öffnet.
Alle Branches mit Ausnahme des Default Branches können über den Mülleimer-Icon unwiderruflich gelöscht werden.
Über den "Branch erstellen"-Button gelangt man zum Formular, um neue Branches anzulegen.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 204 KiB

View File

@@ -4,6 +4,8 @@ subtitle: Branches
---
### Overview
The branches overview shows the branches that are already existing. By clicking on a branch, the details page of the branch is shown.
Branches are split into two lists: Branches whose last commits are at most 30 days older than the head of the default
branch are listed in "Active Branches". The older ones can be found in "Stale Branches".
The tag "Default" shows which branch is currently set as the default branch of the repository in SCM-Manager. The default branch is always shown first when opening the repository in SCM-Manager.
All branches except the default branch of the repository can be deleted by clicking on the trash bin icon.

View File

@@ -24,8 +24,6 @@
package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.MoreObjects;
import com.google.common.base.Objects;
import sonia.scm.Validateable;
@@ -34,10 +32,9 @@ import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import java.io.Serializable;
import java.util.Optional;
import java.util.regex.Pattern;
//~--- JDK imports ------------------------------------------------------------
/**
* Represents a branch in a repository.
*
@@ -46,73 +43,100 @@ import java.util.regex.Pattern;
*/
@XmlRootElement(name = "branch")
@XmlAccessorType(XmlAccessType.FIELD)
public final class Branch implements Serializable, Validateable
{
public final class Branch implements Serializable, Validateable {
private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>";
private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/.";
public static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?";
public static final Pattern VALID_BRANCH_NAME_PATTERN = Pattern.compile(VALID_BRANCH_NAMES);
/** Field description */
private static final long serialVersionUID = -4602244691711222413L;
//~--- constructors ---------------------------------------------------------
private String name;
private String revision;
private boolean defaultBranch;
private Long lastCommitDate;
private boolean stale = false;
/**
* Constructs a new instance of branch.
* This constructor should only be called from JAXB.
*
*/
Branch() {}
/**
* Constructs a new branch.
*
* @param name name of the branch
* @param revision latest revision of the branch
* @param defaultBranch Whether this branch is the default branch for the repository
*
* @deprecated Use {@link Branch#Branch(String, String, boolean, Long)} instead.
*/
@Deprecated
Branch(String name, String revision, boolean defaultBranch) {
this(name, revision, defaultBranch, null);
}
/**
* Constructs a new branch.
*
* @param name name of the branch
* @param revision latest revision of the branch
* @param defaultBranch Whether this branch is the default branch for the repository
* @param lastCommitDate The date of the commit this branch points to (if computed). May be <code>null</code>
*/
Branch(String name, String revision, boolean defaultBranch)
{
Branch(String name, String revision, boolean defaultBranch, Long lastCommitDate) {
this.name = name;
this.revision = revision;
this.defaultBranch = defaultBranch;
this.lastCommitDate = lastCommitDate;
}
/**
* @deprecated Use {@link #normalBranch(String, String, Long)} instead to set the date of the last commit, too.
*/
@Deprecated
public static Branch normalBranch(String name, String revision) {
return new Branch(name, revision, false);
return normalBranch(name, revision, null);
}
public static Branch normalBranch(String name, String revision, Long lastCommitDate) {
return new Branch(name, revision, false, lastCommitDate);
}
/**
* @deprecated Use {@link #defaultBranch(String, String, Long)} instead to set the date of the last commit, too.
*/
@Deprecated
public static Branch defaultBranch(String name, String revision) {
return new Branch(name, revision, true);
return defaultBranch(name, revision, null);
}
//~--- methods --------------------------------------------------------------
public static Branch defaultBranch(String name, String revision, Long lastCommitDate) {
return new Branch(name, revision, true, lastCommitDate);
}
public void setStale(boolean stale) {
this.stale = stale;
}
@Override
public boolean isValid() {
return VALID_BRANCH_NAME_PATTERN.matcher(name).matches();
}
/**
* {@inheritDoc}
*
*
* @param obj
*
* @return
*/
@Override
public boolean equals(Object obj)
{
if (obj == null)
{
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (getClass() != obj.getClass())
{
if (getClass() != obj.getClass()) {
return false;
}
@@ -120,48 +144,31 @@ public final class Branch implements Serializable, Validateable
return Objects.equal(name, other.name)
&& Objects.equal(revision, other.revision)
&& Objects.equal(defaultBranch, other.defaultBranch);
&& Objects.equal(defaultBranch, other.defaultBranch)
&& Objects.equal(lastCommitDate, other.lastCommitDate);
}
/**
* {@inheritDoc}
*
*
* @return
*/
@Override
public int hashCode()
{
public int hashCode() {
return Objects.hashCode(name, revision);
}
/**
* {@inheritDoc}
*
*
* @return
*/
@Override
public String toString()
{
//J-
public String toString() {
return MoreObjects.toStringHelper(this)
.add("name", name)
.add("revision", revision)
.add("defaultBranch", defaultBranch)
.add("lastCommitDate", lastCommitDate)
.toString();
//J+
}
//~--- get methods ----------------------------------------------------------
/**
* Returns the name of the branch
*
*
* @return name of the branch
*/
public String getName()
{
public String getName() {
return name;
}
@@ -170,22 +177,27 @@ public final class Branch implements Serializable, Validateable
*
* @return latest revision of branch
*/
public String getRevision()
{
public String getRevision() {
return revision;
}
/**
* Flag whether this branch is configured as the default branch.
*/
public boolean isDefaultBranch() {
return defaultBranch;
}
//~--- fields ---------------------------------------------------------------
/**
* The date of the commit this branch points to, if this was computed (can be empty).
*
* @since 2.11.0
*/
public Optional<Long> getLastCommitDate() {
return Optional.ofNullable(lastCommitDate);
}
/** name of the branch */
private String name;
/** Field description */
private String revision;
private boolean defaultBranch;
public boolean isStale() {
return stale;
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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.repository.api;
import sonia.scm.repository.Branch;
import sonia.scm.repository.spi.BranchStaleComputer;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static java.time.Instant.ofEpochMilli;
public class BranchXDaysOlderThanDefaultStaleComputer implements BranchStaleComputer {
public static final int DEFAULT_AMOUNT_OF_DAYS = 30;
private final int amountOfDays;
public BranchXDaysOlderThanDefaultStaleComputer() {
this(DEFAULT_AMOUNT_OF_DAYS);
}
public BranchXDaysOlderThanDefaultStaleComputer(int amountOfDays) {
this.amountOfDays = amountOfDays;
}
@Override
@SuppressWarnings("java:S3655") // we check "isPresent" for both dates, but due to the third check sonar does not get it
public boolean computeStale(Branch branch, StaleContext context) {
Branch defaultBranch = context.getDefaultBranch();
if (shouldCompute(branch, defaultBranch)) {
Instant defaultCommitDate = ofEpochMilli(defaultBranch.getLastCommitDate().get());
Instant thisCommitDate = ofEpochMilli(branch.getLastCommitDate().get());
return thisCommitDate.plus(amountOfDays, ChronoUnit.DAYS).isBefore(defaultCommitDate);
} else {
return false;
}
}
public boolean shouldCompute(Branch branch, Branch defaultBranch) {
return !branch.isDefaultBranch() && branch.getLastCommitDate().isPresent() && defaultBranch.getLastCommitDate().isPresent();
}
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import com.google.common.base.Objects;
@@ -165,7 +165,7 @@ public final class BranchesCommandBuilder
private Branches getBranchesFromCommand()
throws IOException
{
return new Branches(branchesCommand.getBranches());
return new Branches(branchesCommand.getBranchesWithStaleFlags(new BranchXDaysOlderThanDefaultStaleComputer()));
}
//~--- inner classes --------------------------------------------------------

View File

@@ -0,0 +1,38 @@
/*
* 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.repository.spi;
import lombok.Data;
import sonia.scm.repository.Branch;
public interface BranchStaleComputer {
boolean computeStale(Branch branch, StaleContext context);
@Data
class StaleContext {
private Branch defaultBranch;
}
}

View File

@@ -21,33 +21,52 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
package sonia.scm.repository.spi;
import sonia.scm.repository.Branch;
import java.io.IOException;
import java.util.List;
//~--- JDK imports ------------------------------------------------------------
import java.util.Optional;
/**
*
* @author Sebastian Sdorra
* @since 1.18
*/
public interface BranchesCommand
{
public interface BranchesCommand {
/**
* Method description
*
*
* @return
*
* @throws IOException
*/
List<Branch> getBranches() throws IOException;
default List<Branch> getBranchesWithStaleFlags(BranchStaleComputer computer) throws IOException {
List<Branch> branches = getBranches();
new StaleProcessor(computer, branches).process();
return branches;
}
final class StaleProcessor {
private final BranchStaleComputer computer;
private final List<Branch> branches;
private StaleProcessor(BranchStaleComputer computer, List<Branch> branches) {
this.computer = computer;
this.branches = branches;
}
private void process() {
Optional<Branch> defaultBranch = branches.stream()
.filter(Branch::isDefaultBranch)
.findFirst();
defaultBranch.ifPresent(this::process);
}
private void process(Branch defaultBranch) {
BranchStaleComputer.StaleContext staleContext = new BranchStaleComputer.StaleContext();
staleContext.setDefaultBranch(defaultBranch);
branches.forEach(branch -> branch.setStale(computer.computeStale(branch, staleContext)));
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.repository.api;
import org.junit.jupiter.api.Test;
import sonia.scm.repository.Branch;
import sonia.scm.repository.spi.BranchStaleComputer;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static java.time.Instant.now;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.repository.Branch.defaultBranch;
import static sonia.scm.repository.Branch.normalBranch;
class BranchXDaysOlderThanDefaultStaleComputerTest {
Instant now = now();
BranchXDaysOlderThanDefaultStaleComputer computer = new BranchXDaysOlderThanDefaultStaleComputer(30);
@Test
void shouldTagOldBranchAsStale() {
long staleTime =
now
.minus(30, ChronoUnit.DAYS)
.minus(1, ChronoUnit.MINUTES)
.toEpochMilli();
Branch branch = normalBranch("hog", "42", staleTime);
boolean stale = computer.computeStale(branch, createStaleContext());
assertThat(stale).isTrue();
}
@Test
void shouldNotTagNotSoOldBranchAsStale() {
long activeTime =
now
.minus(30, ChronoUnit.DAYS)
.plus(1, ChronoUnit.MINUTES)
.toEpochMilli();
Branch branch = normalBranch("hog", "42", activeTime);
boolean stale = computer.computeStale(branch, createStaleContext());
assertThat(stale).isFalse();
}
@Test
void shouldNotTagDefaultBranchAsStale() {
long staleTime =
now
.minus(30, ChronoUnit.DAYS)
.minus(1, ChronoUnit.MINUTES)
.toEpochMilli();
Branch branch = defaultBranch("hog", "42", staleTime);
boolean stale = computer.computeStale(branch, createStaleContext());
assertThat(stale).isFalse();
}
BranchStaleComputer.StaleContext createStaleContext() {
BranchStaleComputer.StaleContext staleContext = new BranchStaleComputer.StaleContext();
staleContext.setDefaultBranch(defaultBranch("default", "23", now.toEpochMilli()));
return staleContext;
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.repository.spi;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import sonia.scm.repository.Branch;
import sonia.scm.repository.api.BranchXDaysOlderThanDefaultStaleComputer;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static java.time.Instant.now;
import static java.util.Arrays.asList;
class BranchesCommandTest {
@Test
void shouldMarkEachBranchDependingOnDefaultBranch() throws IOException {
Instant now = now();
long staleTime =
now
.minus(30, ChronoUnit.DAYS)
.minus(1, ChronoUnit.MINUTES)
.toEpochMilli();
long activeTime =
now
.minus(30, ChronoUnit.DAYS)
.plus(1, ChronoUnit.MINUTES)
.toEpochMilli();
List<Branch> branches = asList(
Branch.normalBranch("arthur", "42", staleTime),
Branch.normalBranch("marvin", "42", staleTime),
Branch.defaultBranch("hog", "42", now.toEpochMilli()),
Branch.normalBranch("trillian", "42", activeTime)
);
List<Branch> branchesWithStaleFlags = new BranchesCommand() {
@Override
public List<Branch> getBranches() {
return branches;
}
}.getBranchesWithStaleFlags(new BranchXDaysOlderThanDefaultStaleComputer());
Assertions.assertThat(branchesWithStaleFlags)
.extracting("stale")
.containsExactly(true, true, false, false);
}
}

View File

@@ -24,14 +24,14 @@
package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevWalk;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.repository.Branch;
@@ -44,12 +44,9 @@ import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
//~--- JDK imports ------------------------------------------------------------
import static sonia.scm.repository.GitUtil.getCommit;
import static sonia.scm.repository.GitUtil.getCommitTime;
/**
*
* @author Sebastian Sdorra
*/
public class GitBranchesCommand extends AbstractGitCommand implements BranchesCommand {
private static final Logger LOG = LoggerFactory.getLogger(GitBranchesCommand.class);
@@ -60,23 +57,22 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo
super(context);
}
//~--- get methods ----------------------------------------------------------
@Override
public List<Branch> getBranches() throws IOException {
Git git = createGit();
String defaultBranchName = determineDefaultBranchName(git);
try {
Repository repository = git.getRepository();
try (RevWalk refWalk = new RevWalk(repository)) {
return git
.branchList()
.call()
.stream()
.map(ref -> createBranchObject(defaultBranchName, ref))
.map(ref -> createBranchObject(repository, refWalk, defaultBranchName, ref))
.collect(Collectors.toList());
} catch (GitAPIException ex) {
throw new InternalRepositoryException(repository, "could not read branches", ex);
throw new InternalRepositoryException(this.repository, "could not read branches", ex);
}
}
@@ -86,21 +82,31 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo
}
@Nullable
private Branch createBranchObject(String defaultBranchName, Ref ref) {
private Branch createBranchObject(Repository repository, RevWalk refWalk, String defaultBranchName, Ref ref) {
String branchName = GitUtil.getBranch(ref);
if (branchName == null) {
LOG.warn("could not determine branch name for branch name {} at revision {}", ref.getName(), ref.getObjectId());
return null;
} else {
Long lastCommitDate = getCommitDate(repository, refWalk, branchName, ref);
if (branchName.equals(defaultBranchName)) {
return Branch.defaultBranch(branchName, GitUtil.getId(ref.getObjectId()));
return Branch.defaultBranch(branchName, GitUtil.getId(ref.getObjectId()), lastCommitDate);
} else {
return Branch.normalBranch(branchName, GitUtil.getId(ref.getObjectId()));
return Branch.normalBranch(branchName, GitUtil.getId(ref.getObjectId()), lastCommitDate);
}
}
}
private Long getCommitDate(Repository repository, RevWalk refWalk, String branchName, Ref ref) {
try {
return getCommitTime(getCommit(repository, refWalk, ref));
} catch (IOException e) {
LOG.info("failed to read commit date of branch {} with revision {}", branchName, ref.getName());
return null;
}
}
private String determineDefaultBranchName(Git git) {
String defaultBranchName = context.getConfig().getDefaultBranch();
if (Strings.isNullOrEmpty(defaultBranchName)) {

View File

@@ -24,117 +24,26 @@
package sonia.scm.repository.spi;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.ListBranchCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
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 org.junit.Test;
import sonia.scm.repository.Branch;
import sonia.scm.repository.GitRepositoryConfig;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Optional.of;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GitBranchesCommandTest {
@Mock
GitContext context;
@Mock
Git git;
@Mock
ListBranchCommand listBranchCommand;
@Mock
GitRepositoryConfig gitRepositoryConfig;
GitBranchesCommand branchesCommand;
private Ref master;
@BeforeEach
void initContext() {
when(context.getConfig()).thenReturn(gitRepositoryConfig);
}
@BeforeEach
void initCommand() {
master = createRef("master", "0000");
branchesCommand = new GitBranchesCommand(context) {
@Override
Git createGit() {
return git;
}
@Override
Optional<Ref> getRepositoryHeadRef(Git git) {
return of(master);
}
};
when(git.branchList()).thenReturn(listBranchCommand);
}
public class GitBranchesCommandTest extends AbstractGitCommandTestBase {
@Test
void shouldCreateEmptyListWithoutBranches() throws IOException, GitAPIException {
when(listBranchCommand.call()).thenReturn(emptyList());
public void shouldReadBranches() throws IOException {
GitBranchesCommand branchesCommand = new GitBranchesCommand(createContext());
List<Branch> branches = branchesCommand.getBranches();
assertThat(branches).isEmpty();
}
@Test
void shouldMapNormalBranch() throws IOException, GitAPIException {
Ref branch = createRef("branch", "1337");
when(listBranchCommand.call()).thenReturn(asList(branch));
List<Branch> branches = branchesCommand.getBranches();
assertThat(branches).containsExactly(Branch.normalBranch("branch", "1337"));
}
@Test
void shouldMarkMasterBranchWithMasterFromConfig() throws IOException, GitAPIException {
Ref branch = createRef("branch", "1337");
when(listBranchCommand.call()).thenReturn(asList(branch));
when(gitRepositoryConfig.getDefaultBranch()).thenReturn("branch");
List<Branch> branches = branchesCommand.getBranches();
assertThat(branches).containsExactlyInAnyOrder(Branch.defaultBranch("branch", "1337"));
}
@Test
void shouldMarkMasterBranchWithMasterFromHead() throws IOException, GitAPIException {
Ref branch = createRef("branch", "1337");
when(listBranchCommand.call()).thenReturn(asList(branch, master));
List<Branch> branches = branchesCommand.getBranches();
assertThat(branches).containsExactlyInAnyOrder(
Branch.normalBranch("branch", "1337"),
Branch.defaultBranch("master", "0000")
assertThat(branches).contains(
Branch.defaultBranch("master", "fcd0ef1831e4002ac43ea539f4094334c79ea9ec", 1339428655000L),
Branch.normalBranch("mergeable", "91b99de908fcd04772798a31c308a64aea1a5523", 1541586052000L),
Branch.normalBranch("rename", "383b954b27e052db6880d57f1c860dc208795247", 1589203061000L)
);
}
private Ref createRef(String branchName, String revision) {
Ref ref = mock(Ref.class);
lenient().when(ref.getName()).thenReturn("refs/heads/" + branchName);
ObjectId objectId = mock(ObjectId.class);
lenient().when(objectId.name()).thenReturn(revision);
lenient().when(ref.getObjectId()).thenReturn(objectId);
return ref;
}
}

View File

@@ -27,7 +27,6 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.aragost.javahg.Changeset;
import com.google.common.base.Function;
import com.google.common.collect.Lists;
import sonia.scm.repository.Branch;
@@ -63,14 +62,8 @@ public class HgBranchesCommand extends AbstractCommand
List<com.aragost.javahg.Branch> hgBranches =
com.aragost.javahg.commands.BranchesCommand.on(open()).execute();
List<Branch> branches = Lists.transform(hgBranches,
new Function<com.aragost.javahg.Branch,
Branch>()
{
@Override
public Branch apply(com.aragost.javahg.Branch hgBranch)
{
return Lists.transform(hgBranches,
hgBranch -> {
String node = null;
Changeset changeset = hgBranch.getBranchTip();
@@ -79,14 +72,12 @@ public class HgBranchesCommand extends AbstractCommand
node = changeset.getNode();
}
long lastCommitDate = changeset.getTimestamp().getDate().getTime();
if (DEFAULT_BRANCH_NAME.equals(hgBranch.getName())) {
return Branch.defaultBranch(hgBranch.getName(), node);
return Branch.defaultBranch(hgBranch.getName(), node, lastCommitDate);
} else {
return Branch.normalBranch(hgBranch.getName(), node);
return Branch.normalBranch(hgBranch.getName(), node, lastCommitDate);
}
}
});
return branches;
});
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.repository.spi;
import org.junit.Test;
import sonia.scm.repository.Branch;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.repository.Branch.defaultBranch;
import static sonia.scm.repository.Branch.normalBranch;
public class HgBranchesCommandTest extends AbstractHgCommandTestBase {
@Test
public void shouldReadBranches() {
HgBranchesCommand command = new HgBranchesCommand(cmdContext);
List<Branch> branches = command.getBranches();
assertThat(branches).contains(
defaultBranch("default", "2baab8e80280ef05a9aa76c49c76feca2872afb7", 1339586381000L),
normalBranch("test-branch", "79b6baf49711ae675568e0698d730b97ef13e84a", 1339586299000L)
);
}
}

View File

@@ -24,14 +24,14 @@
import { storiesOf } from "@storybook/react";
import { BranchSelector } from "./index";
import { Branch } from "@scm-manager/ui-types/src";
import { Branch } from "@scm-manager/ui-types";
import * as React from "react";
import styled from "styled-components";
const master = { name: "master", revision: "1", defaultBranch: true, _links: {} };
const develop = { name: "develop", revision: "2", defaultBranch: false, _links: {} };
const branchSelected = (branch?: Branch) => {};
const branchSelected = (branch?: Branch) => null;
const branches = [master, develop];
@@ -42,6 +42,4 @@ const Wrapper = styled.div`
storiesOf("BranchSelector", module)
.addDecorator(storyFn => <Wrapper>{storyFn()}</Wrapper>)
.add("Default", () => (
<BranchSelector branches={branches} onSelectBranch={branchSelected} label="Select branch:" />
));
.add("Default", () => <BranchSelector branches={branches} onSelectBranch={branchSelected} label="Select branch:" />);

View File

@@ -28,6 +28,8 @@ export type Branch = {
name: string;
revision: string;
defaultBranch?: boolean;
lastCommitDate?: string;
stale?: boolean;
_links: Links;
};

View File

@@ -64,7 +64,11 @@
"createButton": "Branch erstellen"
},
"table": {
"branches": "Branches"
"branches": {
"active": "Aktive Branches",
"stale": "Alte Branches"
},
"lastCommit": "Letzter Commit"
},
"create": {
"title": "Branch erstellen",

View File

@@ -64,7 +64,11 @@
"createButton": "Create Branch"
},
"table": {
"branches": "Branches"
"branches": {
"active": "Active Branches",
"stale": "Stale Branches"
},
"lastCommit": "Last commit"
},
"create": {
"title": "Create Branch",

View File

@@ -26,21 +26,45 @@ import { Branch, Repository } from "@scm-manager/ui-types";
import { WithTranslation, withTranslation } from "react-i18next";
import BranchButtonGroup from "./BranchButtonGroup";
import DefaultBranchTag from "./DefaultBranchTag";
import { DateFromNow } from "@scm-manager/ui-components";
import styled from "styled-components";
type Props = WithTranslation & {
repository: Repository;
branch: Branch;
};
const FlexRow = styled.div`
display: flex;
align-items: center;
flex-wrap: wrap;
`;
const Created = styled.div`
margin-left: 0.5rem;
font-size: 0.8rem;
`;
const Label = styled.strong`
margin-right: 0.3rem;
`;
const Date = styled(DateFromNow)`
font-size: 0.8rem;
`;
class BranchDetail extends React.Component<Props> {
render() {
const { repository, branch, t } = this.props;
return (
<div className="media">
<div className="media-content subtitle">
<strong>{t("branch.name")}</strong> {branch.name} <DefaultBranchTag defaultBranch={branch.defaultBranch} />
</div>
<FlexRow className="media-content subtitle">
<Label>{t("branch.name")}</Label> {branch.name} <DefaultBranchTag defaultBranch={branch.defaultBranch} />
<Created className="is-ellipsis-overflow">
{t("tags.overview.created")} <Date date={branch.lastCommitDate} className="has-text-grey" />
</Created>
</FlexRow>
<div className="media-right">
<BranchButtonGroup repository={repository} branch={branch} />
</div>

View File

@@ -25,8 +25,9 @@ import React, { FC } from "react";
import { Link as ReactLink } from "react-router-dom";
import { Branch, Link } from "@scm-manager/ui-types";
import DefaultBranchTag from "./DefaultBranchTag";
import { Icon } from "@scm-manager/ui-components";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
type Props = {
baseUrl: string;
@@ -34,6 +35,11 @@ type Props = {
onDelete: (branch: Branch) => void;
};
const Created = styled.span`
margin-left: 1rem;
font-size: 0.8rem;
`;
const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
const [t] = useTranslation("repos");
@@ -56,6 +62,11 @@ const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
{branch.name}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</ReactLink>
{branch.lastCommitDate && (
<Created className="has-text-grey is-ellipsis-overflow">
{t("branches.table.lastCommit")} <DateFromNow date={branch.lastCommitDate} />
</Created>
)}
</td>
<td className="is-darker">{deleteButton}</td>
</tr>

View File

@@ -30,10 +30,11 @@ import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-comp
type Props = {
baseUrl: string;
branches: Branch[];
type: string;
fetchBranches: () => void;
};
const BranchTable: FC<Props> = ({ baseUrl, branches, fetchBranches }) => {
const BranchTable: FC<Props> = ({ baseUrl, branches, type, fetchBranches }) => {
const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
@@ -92,7 +93,7 @@ const BranchTable: FC<Props> = ({ baseUrl, branches, fetchBranches }) => {
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
<th>{t("branches.table.branches")}</th>
<th>{t(`branches.table.branches.${type}`)}</th>
</tr>
</thead>
<tbody>{renderRow()}</tbody>

View File

@@ -84,7 +84,28 @@ class BranchesOverview extends React.Component<Props> {
const { baseUrl, branches, repository, fetchBranches, t } = this.props;
if (branches && branches.length > 0) {
orderBranches(branches);
return <BranchTable baseUrl={baseUrl} branches={branches} fetchBranches={() => fetchBranches(repository)} />;
const staleBranches = branches.filter(b => b.stale);
const activeBranches = branches.filter(b => !b.stale);
return (
<>
{activeBranches.length > 0 && (
<BranchTable
baseUrl={baseUrl}
type={"active"}
branches={activeBranches}
fetchBranches={() => fetchBranches(repository)}
/>
)}
{staleBranches.length > 0 && (
<BranchTable
baseUrl={baseUrl}
type={"stale"}
branches={staleBranches}
fetchBranches={() => fetchBranches(repository)}
/>
)}
</>
);
}
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}

View File

@@ -24,6 +24,7 @@
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,11 +32,14 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.validator.constraints.Length;
import sonia.scm.repository.Branch;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES;
import java.time.Instant;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
@Getter
@Setter
@@ -45,10 +49,13 @@ public class BranchDto extends HalRepresentation {
@NotEmpty
@Length(min = 1, max = 100)
@Pattern(regexp = VALID_BRANCH_NAMES)
@Pattern(regexp = Branch.VALID_BRANCH_NAMES)
private String name;
private String revision;
private boolean defaultBranch;
@JsonInclude(NON_NULL)
private Instant lastCommitDate;
private boolean stale;
BranchDto(Links links, Embedded embedded) {
super(links, embedded);

View File

@@ -38,11 +38,14 @@ import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import java.time.Instant;
import java.util.Optional;
import static de.otto.edison.hal.Link.linkBuilder;
import static de.otto.edison.hal.Links.linkingTo;
@Mapper
public abstract class BranchToBranchDtoMapper extends HalAppenderMapper {
public abstract class BranchToBranchDtoMapper extends HalAppenderMapper implements InstantAttributeMapper {
@Inject
private ResourceLinks resourceLinks;
@@ -68,4 +71,8 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper {
return new BranchDto(linksBuilder.build(), embeddedBuilder.build());
}
Instant mapOptionalTime(Optional<Long> date) {
return date.map(this::mapTime).orElse(null);
}
}