diff --git a/docs/de/user/repo/assets/repository-branches-overview.png b/docs/de/user/repo/assets/repository-branches-overview.png index c839e6b57c..7f890f689b 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 47ac022067..923deb537e 100644 --- a/docs/de/user/repo/branches.md +++ b/docs/de/user/repo/branches.md @@ -6,9 +6,11 @@ subtitle: Branches 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. +Neben dem Datum der letzten Änderung und dem Autor dieser Änderung werden auch die Anzahl der Commits vor bzw. nach dem Default Branch angezeigt. +Mit diesen zwei Zahlen wird ersichtlich, wie weit sich dieser Branch vom Default Branch entfernt hat. -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. +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, sobald das Repository im SCM-Manager geöffnet wird. +Alle Branches mit Ausnahme des Default Branches können über das 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-branches-overview.png b/docs/en/user/repo/assets/repository-branches-overview.png index 08a2ab19df..c9975c2e8e 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 7a9d318786..fba7fc1381 100644 --- a/docs/en/user/repo/branches.md +++ b/docs/en/user/repo/branches.md @@ -6,6 +6,8 @@ subtitle: Branches 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". +Besides the date of the last change and the author of this change, you will also find the ahead/behind commits related to the default branch. +With this information you can see how far this branch has diverged from the default branch. 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/gradle/changelog/branch_details.yaml b/gradle/changelog/branch_details.yaml new file mode 100644 index 0000000000..809ea2bbe3 --- /dev/null +++ b/gradle/changelog/branch_details.yaml @@ -0,0 +1,2 @@ +- type: added + description: Show additional information on branches overview ([#1876](https://github.com/scm-manager/scm-manager/pull/1876)) 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 acfdd6a884..bf13f9f50b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Branch.java +++ b/scm-core/src/main/java/sonia/scm/repository/Branch.java @@ -59,6 +59,7 @@ public final class Branch implements Serializable, Validateable { private boolean defaultBranch; private Long lastCommitDate; + private Person lastCommitter; private boolean stale = false; @@ -66,16 +67,16 @@ public final class Branch implements Serializable, Validateable { * Constructs a new instance of branch. * This constructor should only be called from JAXB. */ - Branch() {} + Branch() { + } /** * Constructs a new branch. * - * @param name name of the branch - * @param revision latest revision of the 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 Use {@link Branch#Branch(String, String, boolean, Long, Person)} instead. */ @Deprecated Branch(String name, String revision, boolean defaultBranch) { @@ -85,28 +86,52 @@ public final class Branch implements Serializable, Validateable { /** * 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 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 + * @deprecated Use {@link Branch#Branch(String, String, boolean, Long, Person)} instead. */ + @Deprecated Branch(String name, String revision, boolean defaultBranch, Long lastCommitDate) { + this(name, revision, defaultBranch, lastCommitDate, 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 + * @param lastCommitter The user of the commit this branch points to (if computed). May be null + */ + Branch(String name, String revision, boolean defaultBranch, Long lastCommitDate, Person lastCommitter) { this.name = name; this.revision = revision; this.defaultBranch = defaultBranch; this.lastCommitDate = lastCommitDate; + this.lastCommitter = lastCommitter; } /** - * @deprecated Use {@link #normalBranch(String, String, Long)} instead to set the date of the last commit, too. + * @deprecated Use {@link #normalBranch(String, String, Long, Person)} instead to set the date of the last commit, too. */ @Deprecated public static Branch normalBranch(String name, String revision) { return normalBranch(name, revision, null); } + /** + * @deprecated Use {@link #normalBranch(String, String, Long, Person)} instead to set the author of the last commit, too. + */ + @Deprecated public static Branch normalBranch(String name, String revision, Long lastCommitDate) { - return new Branch(name, revision, false, lastCommitDate); + return normalBranch(name, revision, lastCommitDate, null); + } + + public static Branch normalBranch(String name, String revision, Long lastCommitDate, Person lastCommitter) { + return new Branch(name, revision, false, lastCommitDate, lastCommitter); } /** @@ -117,8 +142,16 @@ public final class Branch implements Serializable, Validateable { return defaultBranch(name, revision, null); } + /** + * @deprecated Use {@link #defaultBranch(String, String, Long, Person)} instead to set the author of the last commit, too. + */ + @Deprecated public static Branch defaultBranch(String name, String revision, Long lastCommitDate) { - return new Branch(name, revision, true, lastCommitDate); + return defaultBranch(name, revision, lastCommitDate, null); + } + + public static Branch defaultBranch(String name, String revision, Long lastCommitDate, Person lastCommitter) { + return new Branch(name, revision, true, lastCommitDate, lastCommitter); } public void setStale(boolean stale) { @@ -145,7 +178,8 @@ 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(lastCommitDate, other.lastCommitDate); + && Objects.equal(lastCommitDate, other.lastCommitDate) + && Objects.equal(lastCommitter, other.lastCommitter); } @Override @@ -156,11 +190,12 @@ public final class Branch implements Serializable, Validateable { @Override public String toString() { return MoreObjects.toStringHelper(this) - .add("name", name) - .add("revision", revision) - .add("defaultBranch", defaultBranch) - .add("lastCommitDate", lastCommitDate) - .toString(); + .add("name", name) + .add("revision", revision) + .add("defaultBranch", defaultBranch) + .add("lastCommitDate", lastCommitDate) + .add("lastCommitter", lastCommitter) + .toString(); } /** @@ -197,6 +232,16 @@ public final class Branch implements Serializable, Validateable { return Optional.ofNullable(lastCommitDate); } + + /** + * The author of the last commit this branch points to. + * + * @since 2.28.0 + */ + public Person getLastCommitter() { + return lastCommitter; + } + public boolean isStale() { return stale; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BranchDetailsCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/BranchDetailsCommandBuilder.java new file mode 100644 index 0000000000..c7ef6aac1e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/BranchDetailsCommandBuilder.java @@ -0,0 +1,98 @@ +/* + * 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 lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryCacheKey; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.spi.BranchDetailsCommand; +import sonia.scm.repository.spi.BranchDetailsCommandRequest; + +import java.io.Serializable; + +/** + * @since 2.28.0 + */ +public final class BranchDetailsCommandBuilder { + + static final String CACHE_NAME = "sonia.cache.cmd.branch-details"; + private static final Logger LOG = LoggerFactory.getLogger(BranchDetailsCommandBuilder.class); + + private final Repository repository; + private final BranchDetailsCommand command; + private final Cache cache; + + public BranchDetailsCommandBuilder(Repository repository, BranchDetailsCommand command, CacheManager cacheManager) { + this.repository = repository; + this.command = command; + this.cache = cacheManager.getCache(CACHE_NAME); + } + + /** + * Computes the details for the given branch. + * + * @param branchName Tha name of the branch the details should be computed for. + * @return The result object containing the details for the branch. + */ + public BranchDetailsCommandResult execute(String branchName) { + LOG.debug("get branch details for repository {} and branch {}", repository, branchName); + RepositoryPermissions.read(repository).check(); + BranchDetailsCommandRequest branchDetailsCommandRequest = new BranchDetailsCommandRequest(); + branchDetailsCommandRequest.setBranchName(branchName); + BranchDetailsCommandResult cachedResult = cache.get(createCacheKey(branchName)); + if (cachedResult != null) { + LOG.debug("got result from cache for repository {} and branch {}", repository, branchName); + return cachedResult; + } + + BranchDetailsCommandResult result = command.execute(branchDetailsCommandRequest); + cache.put(createCacheKey(branchName), result); + return result; + } + + private CacheKey createCacheKey(String branchName) { + return new CacheKey(repository, branchName); + } + + @AllArgsConstructor + @Getter + @EqualsAndHashCode + static class CacheKey implements RepositoryCacheKey, Serializable { + private Repository repository; + private String branchName; + + @Override + public String getRepositoryId() { + return repository.getId(); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BranchDetailsCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/BranchDetailsCommandResult.java new file mode 100644 index 0000000000..484f25758b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/BranchDetailsCommandResult.java @@ -0,0 +1,66 @@ +/* + * 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 java.util.Optional; + +import static java.util.Optional.ofNullable; + +/** + * @since 2.28.0 + */ +public class BranchDetailsCommandResult { + private final Integer changesetsAhead; + private final Integer changesetsBehind; + + /** + * Creates the result object + * + * @param changesetsAhead The number of changesets this branch is ahead of the default branch (that is + * the number of changesets on this branch that are not reachable from the default branch). + * @param changesetsBehind The number of changesets the default branch is ahead of this branch (that is + * the number of changesets on the default branch that are not reachable from this branch). + */ + public BranchDetailsCommandResult(Integer changesetsAhead, Integer changesetsBehind) { + this.changesetsAhead = changesetsAhead; + this.changesetsBehind = changesetsBehind; + } + + /** + * The number of changesets this branch is ahead of the default branch (that is + * the number of changesets on this branch that are not reachable from the default branch). + */ + public Optional getChangesetsAhead() { + return ofNullable(changesetsAhead); + } + + /** + * The number of changesets the default branch is ahead of this branch (that is + * the number of changesets on the default branch that are not reachable from this branch). + */ + public Optional getChangesetsBehind() { + return ofNullable(changesetsBehind); + } +} 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 c14d0c21c4..c6f3719f57 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 @@ -100,8 +100,7 @@ public final class BranchesCommandBuilder { if (logger.isDebugEnabled()) { - logger.debug("get branches for repository {} with disabled cache", - repository.getName()); + logger.debug("get branches for repository {} with disabled cache", repository); } branches = getBranchesFromCommand(); @@ -125,8 +124,7 @@ public final class BranchesCommandBuilder } else if (logger.isDebugEnabled()) { - logger.debug("get branches for repository {} from cache", - repository.getName()); + logger.debug("get branches for repository {} from cache", repository); } } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index 1d4e87fd59..f0bb827678 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -82,5 +82,10 @@ public enum Command /** * @since 2.26.0 */ - FILE_LOCK + FILE_LOCK, + + /** + * @since 2.28.0 + */ + BRANCH_DETAILS } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 4c369c1a4a..4f2e696946 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -34,6 +34,7 @@ import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryExportingCheck; import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.RepositoryReadOnlyChecker; +import sonia.scm.repository.spi.BranchDetailsCommand; import sonia.scm.repository.spi.RepositoryServiceProvider; import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.security.Authentications; @@ -490,6 +491,19 @@ public final class RepositoryService implements Closeable { return new FileLockCommandBuilder(provider.getFileLockCommand(), repository); } + /** + * Get details for a branch. + * + * @return instance of {@link BranchDetailsCommand} + * @throws CommandNotSupportedException if the command is not supported + * by the implementation of the repository service provider. + * @since 2.28.0 + */ + public BranchDetailsCommandBuilder getBranchDetailsCommand() { + LOG.debug("create branch details command for repository {}", repository); + return new BranchDetailsCommandBuilder(repository, provider.getBranchDetailsCommand(), cacheManager); + } + /** * Returns true if the command is supported by the repository service. * diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java index 66bcbcd2b4..61e01693ee 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java @@ -325,6 +325,7 @@ public final class RepositoryServiceFactory { this.caches.add(cacheManager.getCache(LogCommandBuilder.CACHE_NAME)); this.caches.add(cacheManager.getCache(TagsCommandBuilder.CACHE_NAME)); this.caches.add(cacheManager.getCache(BranchesCommandBuilder.CACHE_NAME)); + this.caches.add(cacheManager.getCache(BranchDetailsCommandBuilder.CACHE_NAME)); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BranchDetailsCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/BranchDetailsCommand.java new file mode 100644 index 0000000000..2b7b3af43f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BranchDetailsCommand.java @@ -0,0 +1,37 @@ +/* + * 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 sonia.scm.repository.api.BranchDetailsCommandResult; + +/** + * @since 2.28.0 + */ +public interface BranchDetailsCommand { + /** + * Computes the details for the given request. + */ + BranchDetailsCommandResult execute(BranchDetailsCommandRequest branchDetailsCommandRequest); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BranchDetailsCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/BranchDetailsCommandRequest.java new file mode 100644 index 0000000000..3675094f8d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BranchDetailsCommandRequest.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * @since 2.28.0 + */ +public final class BranchDetailsCommandRequest { + private String branchName; + + /** + * The name of the branch the details should be computed for. + */ + public String getBranchName() { + return branchName; + } + + /** + * Sets the name of the branch the details should be computed for. + */ + public void setBranchName(String branchName) { + this.branchName = branchName; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index 287bd2cb33..772bdb757d 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -311,4 +311,11 @@ public abstract class RepositoryServiceProvider implements Closeable public FileLockCommand getFileLockCommand() { throw new CommandNotSupportedException(Command.FILE_LOCK); } + + /** + * @since 2.28.0 + */ + public BranchDetailsCommand getBranchDetailsCommand() { + throw new CommandNotSupportedException(Command.BRANCH_DETAILS); + } } diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index d40bbdad01..4b90b258e5 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -53,6 +53,8 @@ public class VndMediaType { public static final String TAG_COLLECTION = PREFIX + "tagCollection" + SUFFIX; public static final String TAG_REQUEST = PREFIX + "tagRequest" + SUFFIX; public static final String BRANCH = PREFIX + "branch" + SUFFIX; + public static final String BRANCH_DETAILS = PREFIX + "branchDetails" + SUFFIX; + public static final String BRANCH_DETAILS_COLLECTION = PREFIX + "branchDetailsCollection" + SUFFIX; public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX; public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX; public static final String DIFF_PARSED = PREFIX + "diffParsed" + SUFFIX; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchDetailsCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchDetailsCommand.java new file mode 100644 index 0000000000..d98d85b41e --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchDetailsCommand.java @@ -0,0 +1,98 @@ +/* + * 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.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.revwalk.RevWalkUtils; +import org.eclipse.jgit.revwalk.filter.RevFilter; +import sonia.scm.repository.Branch; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.api.BranchDetailsCommandResult; + +import javax.inject.Inject; +import java.io.IOException; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class GitBranchDetailsCommand extends AbstractGitCommand implements BranchDetailsCommand { + + @Inject + GitBranchDetailsCommand(GitContext context) { + super(context); + } + + @Override + public BranchDetailsCommandResult execute(BranchDetailsCommandRequest branchDetailsCommandRequest) { + String defaultBranch = context.getConfig().getDefaultBranch(); + if (branchDetailsCommandRequest.getBranchName().equals(defaultBranch)) { + return new BranchDetailsCommandResult(0, 0); + } + try { + Repository repository = open(); + ObjectId branchCommit = getObjectId(branchDetailsCommandRequest.getBranchName(), repository); + ObjectId defaultCommit = getObjectId(defaultBranch, repository); + return computeAheadBehind(repository, branchCommit, defaultCommit); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "could not compute ahead/behind", e); + } + } + + private ObjectId getObjectId(String branch, Repository repository) throws IOException { + ObjectId branchCommit = getCommitOrDefault(repository, branch); + if (branchCommit == null) { + throw notFound(entity(Branch.class, branch).in(context.getRepository())); + } + return branchCommit; + } + + private BranchDetailsCommandResult computeAheadBehind(Repository repository, ObjectId branchCommit, ObjectId defaultCommit) throws MissingObjectException, IncorrectObjectTypeException { + // this implementation is a copy of the implementation in org.eclipse.jgit.lib.BranchTrackingStatus + try (RevWalk walk = new RevWalk(repository)) { + + RevCommit localCommit = walk.parseCommit(branchCommit); + RevCommit trackingCommit = walk.parseCommit(defaultCommit); + + walk.setRevFilter(RevFilter.MERGE_BASE); + walk.markStart(localCommit); + walk.markStart(trackingCommit); + RevCommit mergeBase = walk.next(); + + walk.reset(); + walk.setRevFilter(RevFilter.ALL); + int aheadCount = RevWalkUtils.count(walk, localCommit, mergeBase); + int behindCount = RevWalkUtils.count(walk, trackingCommit, mergeBase); + + return new BranchDetailsCommandResult(aheadCount, behindCount); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "could not compute ahead/behind", e); + } + } +} 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 683e5d0068..f14b5673cf 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 @@ -29,14 +29,17 @@ 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.PersonIdent; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.Branch; import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Person; import javax.inject.Inject; import java.io.IOException; @@ -52,8 +55,7 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo private static final Logger LOG = LoggerFactory.getLogger(GitBranchesCommand.class); @Inject - public GitBranchesCommand(GitContext context) - { + public GitBranchesCommand(GitContext context) { super(context); } @@ -89,24 +91,23 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo 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()), lastCommitDate); - } else { - return Branch.normalBranch(branchName, GitUtil.getId(ref.getObjectId()), lastCommitDate); + try { + RevCommit commit = getCommit(repository, refWalk, ref); + Long lastCommitDate = getCommitTime(commit); + PersonIdent authorIdent = commit.getAuthorIdent(); + Person lastCommitter = new Person(authorIdent.getName(), authorIdent.getEmailAddress()); + if (branchName.equals(defaultBranchName)) { + return Branch.defaultBranch(branchName, GitUtil.getId(ref.getObjectId()), lastCommitDate, lastCommitter); + } else { + return Branch.normalBranch(branchName, GitUtil.getId(ref.getObjectId()), lastCommitDate, lastCommitter); + } + } catch (IOException e) { + LOG.info("failed to read commit date/author of branch {} with revision {}", branchName, ref.getName()); + return null; } } } - 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/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index ba4bd19b1f..38337906eb 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -58,7 +58,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { Command.BUNDLE, Command.UNBUNDLE, Command.MIRROR, - Command.FILE_LOCK + Command.FILE_LOCK, + Command.BRANCH_DETAILS ); protected static final Set FEATURES = EnumSet.of( @@ -186,6 +187,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { return commandInjector.getInstance(GitFileLockCommand.class); } + @Override + public BranchDetailsCommand getBranchDetailsCommand() { + return commandInjector.getInstance(GitBranchDetailsCommand.class); + } + @Override public Set getSupportedCommands() { return COMMANDS; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchDetailsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchDetailsCommandTest.java new file mode 100644 index 0000000000..9a7c69f966 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchDetailsCommandTest.java @@ -0,0 +1,79 @@ +/* + * 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.NotFoundException; +import sonia.scm.repository.api.BranchDetailsCommandResult; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GitBranchDetailsCommandTest extends AbstractGitCommandTestBase { + + @Test + public void shouldGetZerosForDefaultBranch() { + GitBranchDetailsCommand command = new GitBranchDetailsCommand(createContext()); + + BranchDetailsCommandRequest request = new BranchDetailsCommandRequest(); + request.setBranchName("master"); + BranchDetailsCommandResult result = command.execute(request); + + assertThat(result.getChangesetsAhead()).get().isEqualTo(0); + assertThat(result.getChangesetsBehind()).get().isEqualTo(0); + } + + @Test + public void shouldCountSimpleAheadAndBehind() { + GitBranchDetailsCommand command = new GitBranchDetailsCommand(createContext()); + + BranchDetailsCommandRequest request = new BranchDetailsCommandRequest(); + request.setBranchName("test-branch"); + BranchDetailsCommandResult result = command.execute(request); + + assertThat(result.getChangesetsAhead()).get().isEqualTo(1); + assertThat(result.getChangesetsBehind()).get().isEqualTo(2); + } + + @Test + public void shouldCountMoreComplexAheadAndBehind() { + GitBranchDetailsCommand command = new GitBranchDetailsCommand(createContext()); + + BranchDetailsCommandRequest request = new BranchDetailsCommandRequest(); + request.setBranchName("partially_merged"); + BranchDetailsCommandResult result = command.execute(request); + + assertThat(result.getChangesetsAhead()).get().isEqualTo(3); + assertThat(result.getChangesetsBehind()).get().isEqualTo(1); + } + + @Test(expected = NotFoundException.class) + public void shouldThrowNotFoundExceptionForUnknownBranch() { + GitBranchDetailsCommand command = new GitBranchDetailsCommand(createContext()); + + BranchDetailsCommandRequest request = new BranchDetailsCommandRequest(); + request.setBranchName("no-such-branch"); + command.execute(request); + } +} 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 fddf386d92..e0730855c5 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 @@ -26,11 +26,14 @@ package sonia.scm.repository.spi; import org.junit.Test; import sonia.scm.repository.Branch; +import sonia.scm.repository.Person; import java.io.IOException; 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 GitBranchesCommandTest extends AbstractGitCommandTestBase { @@ -40,10 +43,35 @@ public class GitBranchesCommandTest extends AbstractGitCommandTestBase { List branches = branchesCommand.getBranches(); - assertThat(branches).contains( - Branch.defaultBranch("master", "fcd0ef1831e4002ac43ea539f4094334c79ea9ec", 1339428655000L), - Branch.normalBranch("mergeable", "91b99de908fcd04772798a31c308a64aea1a5523", 1541586052000L), - Branch.normalBranch("rename", "383b954b27e052db6880d57f1c860dc208795247", 1589203061000L) + assertThat(findBranch(branches, "master")).isEqualTo( + defaultBranch( + "master", + "fcd0ef1831e4002ac43ea539f4094334c79ea9ec", + 1339428655000L, + new Person("Zaphod Beeblebrox", "zaphod.beeblebrox@hitchhiker.com") + ) + ); + assertThat(findBranch(branches, "mergeable")).isEqualTo( + normalBranch( + "mergeable", + "91b99de908fcd04772798a31c308a64aea1a5523", + 1541586052000L, + new Person("Douglas Adams", + "douglas.adams@hitchhiker.com") + ) + ); + assertThat(findBranch(branches, "rename")).isEqualTo( + normalBranch( + "rename", + "383b954b27e052db6880d57f1c860dc208795247", + 1589203061000L, + new Person("scmadmin", + "scm@admin.com") + ) ); } + + private Branch findBranch(List branches, String mergeable) { + return branches.stream().filter(b -> b.getName().equals(mergeable)).findFirst().get(); + } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchDetailsCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchDetailsCommand.java new file mode 100644 index 0000000000..d9261fd639 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchDetailsCommand.java @@ -0,0 +1,83 @@ +/* + * 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.javahg.Changeset; +import org.javahg.commands.ExecutionException; +import org.javahg.commands.LogCommand; +import sonia.scm.repository.Branch; +import sonia.scm.repository.api.BranchDetailsCommandResult; + +import javax.inject.Inject; +import java.util.List; + +import static org.javahg.commands.flags.LogCommandFlags.on; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.repository.spi.javahg.AbstractChangesetCommand.CHANGESET_LAZY_STYLE_PATH; + +public class HgBranchDetailsCommand implements BranchDetailsCommand { + + private static final String DEFAULT_BRANCH_NAME = "default"; + + private final HgCommandContext context; + + @Inject + HgBranchDetailsCommand(HgCommandContext context) { + this.context = context; + } + + @Override + public BranchDetailsCommandResult execute(BranchDetailsCommandRequest request) { + if (request.getBranchName().equals(DEFAULT_BRANCH_NAME)) { + return new BranchDetailsCommandResult(0,0); + } + + try { + List behind = getChangesetsSolelyOnBranch(DEFAULT_BRANCH_NAME, request.getBranchName()); + List ahead = getChangesetsSolelyOnBranch(request.getBranchName(), DEFAULT_BRANCH_NAME); + + return new BranchDetailsCommandResult(ahead.size(), behind.size()); + } catch (ExecutionException e) { + if (e.getMessage().contains("unknown revision '")) { + throw notFound(entity(Branch.class, request.getBranchName()).in(context.getScmRepository())); + } + throw e; + } + } + + private List getChangesetsSolelyOnBranch(String branch, String reference) { + LogCommand logCommand = on(context.open()).rev( + String.format( + "'%s' %% '%s'", + branch, + reference + ) + ); + logCommand.cmdAppend("--style", CHANGESET_LAZY_STYLE_PATH); + return logCommand.execute(); + } + +} 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 6355afbe8d..e8bf40897a 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 @@ -29,6 +29,7 @@ package sonia.scm.repository.spi; import org.javahg.Changeset; import com.google.common.collect.Lists; import sonia.scm.repository.Branch; +import sonia.scm.repository.Person; import java.util.List; @@ -72,11 +73,12 @@ public class HgBranchesCommand extends AbstractCommand node = changeset.getNode(); } + Person lastCommitter = Person.toPerson(changeset.getUser()); long lastCommitDate = changeset.getTimestamp().getDate().getTime(); if (DEFAULT_BRANCH_NAME.equals(hgBranch.getName())) { - return Branch.defaultBranch(hgBranch.getName(), node, lastCommitDate); + return Branch.defaultBranch(hgBranch.getName(), node, lastCommitDate, lastCommitter); } else { - return Branch.normalBranch(hgBranch.getName(), node, lastCommitDate); + return Branch.normalBranch(hgBranch.getName(), node, lastCommitDate, lastCommitter); } }); } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java index 8bc8c900de..c4ceefcccf 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java @@ -56,7 +56,8 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { Command.MODIFY, Command.BUNDLE, Command.UNBUNDLE, - Command.FULL_HEALTH_CHECK + Command.FULL_HEALTH_CHECK, + Command.BRANCH_DETAILS ); public static final Set FEATURES = EnumSet.of( @@ -187,4 +188,9 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { public FullHealthCheckCommand getFullHealthCheckCommand() { return new HgFullHealthCheckCommand(context); } + + @Override + public BranchDetailsCommand getBranchDetailsCommand() { + return new HgBranchDetailsCommand(context); + } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java index 293d97b3a7..0a848e2f16 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java @@ -64,7 +64,7 @@ public abstract class AbstractChangesetCommand extends AbstractCommand private static final byte[] CHANGESET_PATTERN = Utils.randomBytes(); /** Field description */ - protected static final String CHANGESET_LAZY_STYLE_PATH = + public static final String CHANGESET_LAZY_STYLE_PATH = Utils.resourceAsFile("/sonia/scm/styles/changesets-lazy.style", ImmutableMap.of("pattern", CHANGESET_PATTERN)).getPath(); diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchDetailsCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchDetailsCommandTest.java new file mode 100644 index 0000000000..32c972884a --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchDetailsCommandTest.java @@ -0,0 +1,80 @@ +/* + * 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.NotFoundException; +import sonia.scm.repository.api.BranchDetailsCommandResult; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HgBranchDetailsCommandTest extends AbstractHgCommandTestBase { + + @Test + public void shouldGetSingleBranchDetails() { + BranchDetailsCommandRequest branchRequest = new BranchDetailsCommandRequest(); + branchRequest.setBranchName("testbranch"); + + BranchDetailsCommandResult result = new HgBranchDetailsCommand(cmdContext).execute(branchRequest); + + assertThat(result.getChangesetsAhead()).get().isEqualTo(1); + assertThat(result.getChangesetsBehind()).get().isEqualTo(3); + } + + @Test + public void shouldGetSingleBranchDetailsWithMerge() { + BranchDetailsCommandRequest branchRequest = new BranchDetailsCommandRequest(); + branchRequest.setBranchName("with_merge"); + + BranchDetailsCommandResult result = new HgBranchDetailsCommand(cmdContext).execute(branchRequest); + + assertThat(result.getChangesetsAhead()).get().isEqualTo(5); + assertThat(result.getChangesetsBehind()).get().isEqualTo(1); + } + + @Test + public void shouldGetSingleBranchDetailsWithAnotherMerge() { + BranchDetailsCommandRequest branchRequest = new BranchDetailsCommandRequest(); + branchRequest.setBranchName("next_merge"); + + BranchDetailsCommandResult result = new HgBranchDetailsCommand(cmdContext).execute(branchRequest); + + assertThat(result.getChangesetsAhead()).get().isEqualTo(3); + assertThat(result.getChangesetsBehind()).get().isEqualTo(0); + } + + @Test(expected = NotFoundException.class) + public void shouldThrowNotFoundExceptionForUnknownBranch() { + BranchDetailsCommandRequest branchRequest = new BranchDetailsCommandRequest(); + branchRequest.setBranchName("no-such-branch"); + + new HgBranchDetailsCommand(cmdContext).execute(branchRequest); + } + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-hg-ahead-behind-test.zip"; + } +} 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 index a8f43eb435..8a9afda8b0 100644 --- 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 @@ -26,10 +26,13 @@ package sonia.scm.repository.spi; import org.junit.Test; import sonia.scm.repository.Branch; +import sonia.scm.repository.Person; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static sonia.scm.repository.Branch.defaultBranch; import static sonia.scm.repository.Branch.normalBranch; @@ -41,9 +44,23 @@ public class HgBranchesCommandTest extends AbstractHgCommandTestBase { List branches = command.getBranches(); - assertThat(branches).contains( - defaultBranch("default", "2baab8e80280ef05a9aa76c49c76feca2872afb7", 1339586381000L), - normalBranch("test-branch", "79b6baf49711ae675568e0698d730b97ef13e84a", 1339586299000L) + assertThat(branches).hasSize(2); + assertThat(branches.get(0)).isEqualTo( + defaultBranch( + "default", + "2baab8e80280ef05a9aa76c49c76feca2872afb7", + 1339586381000L, + new Person("Zaphod Beeblebrox", "zaphod.beeblebrox@hitchhiker.com") + ) + ); + assertThat(branches.get(1)).isEqualTo( + normalBranch( + "test-branch", + "79b6baf49711ae675568e0698d730b97ef13e84a", + 1339586299000L, + new Person("Ford Prefect", + "ford.perfect@hitchhiker.com") + ) ); } } diff --git a/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/repository/spi/scm-hg-ahead-behind-test.zip b/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/repository/spi/scm-hg-ahead-behind-test.zip new file mode 100644 index 0000000000..f4a9e045c1 Binary files /dev/null and b/scm-plugins/scm-hg-plugin/src/test/resources/sonia/scm/repository/spi/scm-hg-ahead-behind-test.zip differ diff --git a/scm-ui/ui-api/src/branches.test.ts b/scm-ui/ui-api/src/branches.test.ts index 3c11568c62..35eff358da 100644 --- a/scm-ui/ui-api/src/branches.test.ts +++ b/scm-ui/ui-api/src/branches.test.ts @@ -36,36 +36,38 @@ describe("Test branches hooks", () => { type: "hg", _links: { branches: { - href: "/hog/branches", - }, - }, + href: "/hog/branches" + } + } }; const develop: Branch = { name: "develop", revision: "42", + lastCommitter: { name: "trillian" }, _links: { delete: { - href: "/hog/branches/develop", - }, - }, + href: "/hog/branches/develop" + } + } }; const feature: Branch = { name: "feature/something-special", revision: "42", + lastCommitter: { name: "trillian" }, _links: { delete: { - href: "/hog/branches/feature%2Fsomething-special", - }, - }, + href: "/hog/branches/feature%2Fsomething-special" + } + } }; const branches: BranchCollection = { _embedded: { - branches: [develop], + branches: [develop] }, - _links: {}, + _links: {} }; const queryClient = createInfiniteCachingClient(); @@ -83,7 +85,7 @@ describe("Test branches hooks", () => { fetchMock.getOnce("/api/v2/hog/branches", branches); const { result, waitFor } = renderHook(() => useBranches(repository), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { return !!result.current.data; @@ -104,7 +106,7 @@ describe("Test branches hooks", () => { "repository", "hitchhiker", "heart-of-gold", - "branches", + "branches" ]); expect(data).toEqual(branches); }); @@ -115,7 +117,7 @@ describe("Test branches hooks", () => { fetchMock.getOnce("/api/v2/hog/branches/" + encodeURIComponent(name), branch); const { result, waitFor } = renderHook(() => useBranch(repository, name), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); expect(result.error).toBeUndefined(); @@ -143,14 +145,14 @@ describe("Test branches hooks", () => { fetchMock.postOnce("/api/v2/hog/branches", { status: 201, headers: { - Location: "/hog/branches/develop", - }, + Location: "/hog/branches/develop" + } }); fetchMock.getOnce("/api/v2/hog/branches/develop", develop); const { result, waitForNextUpdate } = renderHook(() => useCreateBranch(repository), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await act(() => { @@ -175,7 +177,7 @@ describe("Test branches hooks", () => { "hitchhiker", "heart-of-gold", "branch", - "develop", + "develop" ]); expect(branch).toEqual(develop); }); @@ -192,11 +194,11 @@ describe("Test branches hooks", () => { describe("useDeleteBranch tests", () => { const deleteBranch = async () => { fetchMock.deleteOnce("/api/v2/hog/branches/develop", { - status: 204, + status: 204 }); const { result, waitForNextUpdate } = renderHook(() => useDeleteBranch(repository), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await act(() => { diff --git a/scm-ui/ui-api/src/branches.ts b/scm-ui/ui-api/src/branches.ts index b752e6786f..d13095dc2d 100644 --- a/scm-ui/ui-api/src/branches.ts +++ b/scm-ui/ui-api/src/branches.ts @@ -21,19 +21,33 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { Branch, BranchCollection, BranchCreation, Link, Repository } from "@scm-manager/ui-types"; +import { + Branch, + BranchCollection, + BranchCreation, + BranchDetailsCollection, + Link, + Repository +} from "@scm-manager/ui-types"; import { requiredLink } from "./links"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "react-query"; import { ApiResult, ApiResultWithFetching } from "./base"; import { branchQueryKey, repoQueryKey } from "./keys"; import { apiClient } from "./apiclient"; import { concat } from "./urls"; +import { useEffect } from "react"; export const useBranches = (repository: Repository): ApiResult => { + const queryClient = useQueryClient(); const link = requiredLink(repository, "branches"); return useQuery( repoQueryKey(repository, "branches"), - () => apiClient.get(link).then((response) => response.json()) + () => apiClient.get(link).then(response => response.json()), + { + onSuccess: () => { + return queryClient.invalidateQueries(branchQueryKey(repository, "details")); + } + } // we do not populate the cache for a single branch, // because we have no pagination for branches and if we have a lot of them // the population slows us down @@ -43,22 +57,80 @@ export const useBranches = (repository: Repository): ApiResult export const useBranch = (repository: Repository, name: string): ApiResultWithFetching => { const link = requiredLink(repository, "branches"); return useQuery(branchQueryKey(repository, name), () => - apiClient.get(concat(link, encodeURIComponent(name))).then((response) => response.json()) + apiClient.get(concat(link, encodeURIComponent(name))).then(response => response.json()) ); }; +export const useBranchDetails = (repository: Repository, branch: string) => { + const link = requiredLink(repository, "branchDetails"); + return useQuery(branchQueryKey(repository, branch, "details"), () => + apiClient.get(concat(link, encodeURIComponent(branch))).then(response => response.json()) + ); +}; + +function chunkBranches(branches: Branch[]) { + const chunks: Branch[][] = []; + const chunkSize = 5; + let chunkIndex = 0; + for (const branch of branches) { + if (!chunks[chunkIndex]) { + chunks[chunkIndex] = []; + } + chunks[chunkIndex].push(branch); + if (chunks[chunkIndex].length >= chunkSize) { + chunkIndex = chunkIndex + 1; + } + } + return chunks; +} + +export const useBranchDetailsCollection = (repository: Repository, branches: Branch[]) => { + const link = requiredLink(repository, "branchDetailsCollection"); + const chunks = chunkBranches(branches); + + const { data, isLoading, error, fetchNextPage } = useInfiniteQuery< + BranchDetailsCollection, + Error, + BranchDetailsCollection + >( + branchQueryKey(repository, "details"), + ({ pageParam = 0 }) => { + const encodedBranches = chunks[pageParam].map(b => encodeURIComponent(b.name)).join("&branches="); + return apiClient.get(concat(link, `?branches=${encodedBranches}`)).then(response => response.json()); + }, + { + getNextPageParam: (lastPage, allPages) => { + if (allPages.length >= chunks.length) { + return undefined; + } + return allPages.length; + } + } + ); + + useEffect(() => { + fetchNextPage(); + }, [data, fetchNextPage]); + + return { + data: data?.pages.map(d => d._embedded?.branchDetails).flat(1), + isLoading, + error + }; +}; + const createBranch = (link: string) => { return (branch: BranchCreation) => { return apiClient .post(link, branch, "application/vnd.scmm-branchRequest+json;v=2") - .then((response) => { + .then(response => { const location = response.headers.get("Location"); if (!location) { throw new Error("Server does not return required Location header"); } return apiClient.get(location); }) - .then((response) => response.json()); + .then(response => response.json()); }; }; @@ -66,23 +138,23 @@ export const useCreateBranch = (repository: Repository) => { const queryClient = useQueryClient(); const link = requiredLink(repository, "branches"); const { mutate, isLoading, error, data } = useMutation(createBranch(link), { - onSuccess: async (branch) => { + onSuccess: async branch => { queryClient.setQueryData(branchQueryKey(repository, branch), branch); await queryClient.invalidateQueries(repoQueryKey(repository, "branches")); - }, + } }); return { create: (branch: BranchCreation) => mutate(branch), isLoading, error, - branch: data, + branch: data }; }; export const useDeleteBranch = (repository: Repository) => { const queryClient = useQueryClient(); const { mutate, isLoading, error, data } = useMutation( - (branch) => { + branch => { const deleteUrl = (branch._links.delete as Link).href; return apiClient.delete(deleteUrl); }, @@ -90,14 +162,14 @@ export const useDeleteBranch = (repository: Repository) => { onSuccess: async (_, branch) => { queryClient.removeQueries(branchQueryKey(repository, branch)); await queryClient.invalidateQueries(repoQueryKey(repository, "branches")); - }, + } } ); return { remove: (branch: Branch) => mutate(branch), isLoading, error, - isDeleted: !!data, + isDeleted: !!data }; }; @@ -106,6 +178,6 @@ type DefaultBranch = { defaultBranch: string }; export const useDefaultBranch = (repository: Repository): ApiResult => { const link = requiredLink(repository, "defaultBranch"); return useQuery(branchQueryKey(repository, "__default-branch"), () => - apiClient.get(link).then((response) => response.json()) + apiClient.get(link).then(response => response.json()) ); }; diff --git a/scm-ui/ui-api/src/changesets.test.ts b/scm-ui/ui-api/src/changesets.test.ts index c14d41c050..79656fea76 100644 --- a/scm-ui/ui-api/src/changesets.test.ts +++ b/scm-ui/ui-api/src/changesets.test.ts @@ -35,19 +35,20 @@ describe("Test changeset hooks", () => { type: "hg", _links: { changesets: { - href: "/r/c", - }, - }, + href: "/r/c" + } + } }; const develop: Branch = { name: "develop", revision: "42", + lastCommitter: { name: "trillian" }, _links: { history: { - href: "/r/b/c", - }, - }, + href: "/r/b/c" + } + } }; const changeset: Changeset = { @@ -55,19 +56,19 @@ describe("Test changeset hooks", () => { description: "Awesome change", date: new Date(), author: { - name: "Arthur Dent", + name: "Arthur Dent" }, _embedded: {}, - _links: {}, + _links: {} }; const changesets: ChangesetCollection = { page: 1, pageTotal: 1, _embedded: { - changesets: [changeset], + changesets: [changeset] }, - _links: {}, + _links: {} }; const expectChangesetCollection = (result?: ChangesetCollection) => { @@ -85,7 +86,7 @@ describe("Test changeset hooks", () => { const queryClient = createInfiniteCachingClient(); const { result, waitFor } = renderHook(() => useChangesets(repository), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { @@ -98,14 +99,14 @@ describe("Test changeset hooks", () => { it("should return changesets for page", async () => { fetchMock.getOnce("/api/v2/r/c", changesets, { query: { - page: 42, - }, + page: 42 + } }); const queryClient = createInfiniteCachingClient(); const { result, waitFor } = renderHook(() => useChangesets(repository, { page: 42 }), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { @@ -121,7 +122,7 @@ describe("Test changeset hooks", () => { const queryClient = createInfiniteCachingClient(); const { result, waitFor } = renderHook(() => useChangesets(repository, { branch: develop }), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { @@ -137,7 +138,7 @@ describe("Test changeset hooks", () => { const queryClient = createInfiniteCachingClient(); const { result, waitFor } = renderHook(() => useChangesets(repository), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { @@ -149,7 +150,7 @@ describe("Test changeset hooks", () => { "hitchhiker", "heart-of-gold", "changeset", - "42", + "42" ]); expect(changeset?.id).toBe("42"); @@ -163,7 +164,7 @@ describe("Test changeset hooks", () => { const queryClient = createInfiniteCachingClient(); const { result, waitFor } = renderHook(() => useChangeset(repository, "42"), { - wrapper: createWrapper(undefined, queryClient), + wrapper: createWrapper(undefined, queryClient) }); await waitFor(() => { diff --git a/scm-ui/ui-components/src/BranchSelector.tsx b/scm-ui/ui-components/src/BranchSelector.tsx index 23934e150c..275916e2b3 100644 --- a/scm-ui/ui-components/src/BranchSelector.tsx +++ b/scm-ui/ui-components/src/BranchSelector.tsx @@ -61,8 +61,8 @@ const BranchSelector: FC = ({ branches, onSelectBranch, selectedBranch, l