diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ede975c00..0d7e1f9fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/de/user/repo/assets/repository-branch-detailView.png b/docs/de/user/repo/assets/repository-branch-detailView.png index d671846fef..f9727d9d4f 100644 Binary files a/docs/de/user/repo/assets/repository-branch-detailView.png and b/docs/de/user/repo/assets/repository-branch-detailView.png differ diff --git a/docs/de/user/repo/assets/repository-branches-overview.png b/docs/de/user/repo/assets/repository-branches-overview.png index 39dcf5e424..c839e6b57c 100644 Binary files a/docs/de/user/repo/assets/repository-branches-overview.png and b/docs/de/user/repo/assets/repository-branches-overview.png differ diff --git a/docs/de/user/repo/branches.md b/docs/de/user/repo/branches.md index 17afe92924..47ac022067 100644 --- a/docs/de/user/repo/branches.md +++ b/docs/de/user/repo/branches.md @@ -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. diff --git a/docs/en/user/repo/assets/repository-branch-detailView.png b/docs/en/user/repo/assets/repository-branch-detailView.png index 615ba696c1..28f768a6be 100644 Binary files a/docs/en/user/repo/assets/repository-branch-detailView.png and b/docs/en/user/repo/assets/repository-branch-detailView.png differ diff --git a/docs/en/user/repo/assets/repository-branches-overview.png b/docs/en/user/repo/assets/repository-branches-overview.png index e63ebab775..08a2ab19df 100644 Binary files a/docs/en/user/repo/assets/repository-branches-overview.png and b/docs/en/user/repo/assets/repository-branches-overview.png differ diff --git a/docs/en/user/repo/branches.md b/docs/en/user/repo/branches.md index 370165710b..7a9d318786 100644 --- a/docs/en/user/repo/branches.md +++ b/docs/en/user/repo/branches.md @@ -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. diff --git a/scm-core/src/main/java/sonia/scm/repository/Branch.java b/scm-core/src/main/java/sonia/scm/repository/Branch.java index bdcbc66a82..acfdd6a884 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Branch.java +++ b/scm-core/src/main/java/sonia/scm/repository/Branch.java @@ -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 null */ - 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 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; + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BranchXDaysOlderThanDefaultStaleComputer.java b/scm-core/src/main/java/sonia/scm/repository/api/BranchXDaysOlderThanDefaultStaleComputer.java new file mode 100644 index 0000000000..15de6663ec --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/BranchXDaysOlderThanDefaultStaleComputer.java @@ -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(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BranchesCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/BranchesCommandBuilder.java index 5ba45b00a0..af784f287b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/BranchesCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/BranchesCommandBuilder.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.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 -------------------------------------------------------- diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BranchStaleComputer.java b/scm-core/src/main/java/sonia/scm/repository/spi/BranchStaleComputer.java new file mode 100644 index 0000000000..c6324fa1cf --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BranchStaleComputer.java @@ -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; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BranchesCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/BranchesCommand.java index ae1468e768..9fa28a49aa 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/BranchesCommand.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BranchesCommand.java @@ -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 getBranches() throws IOException; + + default List getBranchesWithStaleFlags(BranchStaleComputer computer) throws IOException { + List branches = getBranches(); + new StaleProcessor(computer, branches).process(); + return branches; + } + + final class StaleProcessor { + + private final BranchStaleComputer computer; + private final List branches; + + private StaleProcessor(BranchStaleComputer computer, List branches) { + this.computer = computer; + this.branches = branches; + } + + private void process() { + Optional 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))); + } + } } diff --git a/scm-core/src/test/java/sonia/scm/repository/api/BranchXDaysOlderThanDefaultStaleComputerTest.java b/scm-core/src/test/java/sonia/scm/repository/api/BranchXDaysOlderThanDefaultStaleComputerTest.java new file mode 100644 index 0000000000..12fb269162 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/api/BranchXDaysOlderThanDefaultStaleComputerTest.java @@ -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; + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/spi/BranchesCommandTest.java b/scm-core/src/test/java/sonia/scm/repository/spi/BranchesCommandTest.java new file mode 100644 index 0000000000..4a0f9326c0 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/spi/BranchesCommandTest.java @@ -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 branches = asList( + Branch.normalBranch("arthur", "42", staleTime), + Branch.normalBranch("marvin", "42", staleTime), + Branch.defaultBranch("hog", "42", now.toEpochMilli()), + Branch.normalBranch("trillian", "42", activeTime) + ); + + List branchesWithStaleFlags = new BranchesCommand() { + @Override + public List getBranches() { + return branches; + } + }.getBranchesWithStaleFlags(new BranchXDaysOlderThanDefaultStaleComputer()); + + Assertions.assertThat(branchesWithStaleFlags) + .extracting("stale") + .containsExactly(true, true, false, false); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java index d026affd8b..683e5d0068 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java @@ -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 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)) { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java index 967226012a..fddf386d92 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java @@ -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 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 branches = branchesCommand.getBranches(); - assertThat(branches).isEmpty(); - } - - @Test - void shouldMapNormalBranch() throws IOException, GitAPIException { - Ref branch = createRef("branch", "1337"); - when(listBranchCommand.call()).thenReturn(asList(branch)); - - List 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 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 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; - } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchesCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchesCommand.java index 22cccbc8ae..a2285bad11 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchesCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchesCommand.java @@ -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 hgBranches = com.aragost.javahg.commands.BranchesCommand.on(open()).execute(); - List branches = Lists.transform(hgBranches, - new Function() - { - - @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; + }); } } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchesCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchesCommandTest.java new file mode 100644 index 0000000000..a8f43eb435 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchesCommandTest.java @@ -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 branches = command.getBranches(); + + assertThat(branches).contains( + defaultBranch("default", "2baab8e80280ef05a9aa76c49c76feca2872afb7", 1339586381000L), + normalBranch("test-branch", "79b6baf49711ae675568e0698d730b97ef13e84a", 1339586299000L) + ); + } +} diff --git a/scm-ui/ui-components/src/BranchSelector.stories.tsx b/scm-ui/ui-components/src/BranchSelector.stories.tsx index 5abc1a2202..8f59b379f0 100644 --- a/scm-ui/ui-components/src/BranchSelector.stories.tsx +++ b/scm-ui/ui-components/src/BranchSelector.stories.tsx @@ -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 => {storyFn()}) - .add("Default", () => ( - -)); + .add("Default", () => ); diff --git a/scm-ui/ui-types/src/Branches.ts b/scm-ui/ui-types/src/Branches.ts index 87eb5af3dc..949f7b40bc 100644 --- a/scm-ui/ui-types/src/Branches.ts +++ b/scm-ui/ui-types/src/Branches.ts @@ -28,6 +28,8 @@ export type Branch = { name: string; revision: string; defaultBranch?: boolean; + lastCommitDate?: string; + stale?: boolean; _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 6f658daeda..6433fb348a 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -64,7 +64,11 @@ "createButton": "Branch erstellen" }, "table": { - "branches": "Branches" + "branches": { + "active": "Aktive Branches", + "stale": "Alte Branches" + }, + "lastCommit": "Letzter Commit" }, "create": { "title": "Branch erstellen", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index c126c3c7a1..cc4996b937 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -64,7 +64,11 @@ "createButton": "Create Branch" }, "table": { - "branches": "Branches" + "branches": { + "active": "Active Branches", + "stale": "Stale Branches" + }, + "lastCommit": "Last commit" }, "create": { "title": "Create Branch", diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx index 42bfd781b1..224187401f 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx @@ -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 { render() { const { repository, branch, t } = this.props; return (
-
- {t("branch.name")} {branch.name} -
+ + {branch.name} + + {t("tags.overview.created")} + +
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 1acaa78159..b13e0d9893 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx @@ -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 = ({ baseUrl, branch, onDelete }) => { const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`; const [t] = useTranslation("repos"); @@ -56,6 +62,11 @@ const BranchRow: FC = ({ baseUrl, branch, onDelete }) => { {branch.name} + {branch.lastCommitDate && ( + + {t("branches.table.lastCommit")} + + )} {deleteButton} diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx index 312275b663..f78fc5a447 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx @@ -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 = ({ baseUrl, branches, fetchBranches }) => { +const BranchTable: FC = ({ baseUrl, branches, type, fetchBranches }) => { const [t] = useTranslation("repos"); const [showConfirmAlert, setShowConfirmAlert] = useState(false); const [error, setError] = useState(); @@ -92,7 +93,7 @@ const BranchTable: FC = ({ baseUrl, branches, fetchBranches }) => { - + {renderRow()} diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx index 8bbcac53bf..37ff8cf327 100644 --- a/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx @@ -84,7 +84,28 @@ class BranchesOverview extends React.Component { const { baseUrl, branches, repository, fetchBranches, t } = this.props; if (branches && branches.length > 0) { orderBranches(branches); - return fetchBranches(repository)} />; + const staleBranches = branches.filter(b => b.stale); + const activeBranches = branches.filter(b => !b.stale); + return ( + <> + {activeBranches.length > 0 && ( + fetchBranches(repository)} + /> + )} + {staleBranches.length > 0 && ( + fetchBranches(repository)} + /> + )} + + ); } return {t("branches.overview.noBranches")}; } 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 4825291c1b..eb7351983a 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 @@ -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); 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 f82c434b3d..f3860f5708 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 @@ -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 date) { + return date.map(this::mapTime).orElse(null); + } }
{t("branches.table.branches")}{t(`branches.table.branches.${type}`)}