Feature/branch details (#1876)

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -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.

View File

@@ -0,0 +1,2 @@
- type: added
description: Show additional information on branches overview ([#1876](https://github.com/scm-manager/scm-manager/pull/1876))

View File

@@ -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,7 +67,8 @@ 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.
@@ -74,8 +76,7 @@ public final class Branch implements Serializable, Validateable {
* @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) {
@@ -89,24 +90,48 @@ public final class Branch implements Serializable, Validateable {
* @param revision latest revision of the branch
* @param defaultBranch Whether this branch is the default branch for the repository
* @param lastCommitDate The date of the commit this branch points to (if computed). May be <code>null</code>
* @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 <code>null</code>
* @param lastCommitter The user of the commit this branch points to (if computed). May be <code>null</code>
*/
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
@@ -160,6 +194,7 @@ public final class Branch implements Serializable, Validateable {
.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;
}

View File

@@ -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<CacheKey, BranchDetailsCommandResult> 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();
}
}
}

View File

@@ -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<Integer> 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<Integer> getChangesetsBehind() {
return ofNullable(changesetsBehind);
}
}

View File

@@ -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);
}
}

View File

@@ -82,5 +82,10 @@ public enum Command
/**
* @since 2.26.0
*/
FILE_LOCK
FILE_LOCK,
/**
* @since 2.28.0
*/
BRANCH_DETAILS
}

View File

@@ -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.
*

View File

@@ -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));
}
/**

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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,23 +91,22 @@ 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);
}
}
}
private Long getCommitDate(Repository repository, RevWalk refWalk, String branchName, Ref ref) {
try {
return getCommitTime(getCommit(repository, refWalk, ref));
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 of branch {} with revision {}", branchName, ref.getName());
LOG.info("failed to read commit date/author of branch {} with revision {}", branchName, ref.getName());
return null;
}
}
}
private String determineDefaultBranchName(Git git) {
String defaultBranchName = context.getConfig().getDefaultBranch();

View File

@@ -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<Feature> 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<Command> getSupportedCommands() {
return COMMANDS;

View File

@@ -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);
}
}

View File

@@ -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<Branch> 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<Branch> branches, String mergeable) {
return branches.stream().filter(b -> b.getName().equals(mergeable)).findFirst().get();
}
}

View File

@@ -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<Changeset> behind = getChangesetsSolelyOnBranch(DEFAULT_BRANCH_NAME, request.getBranchName());
List<Changeset> 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<Changeset> 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();
}
}

View File

@@ -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);
}
});
}

View File

@@ -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<Feature> 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);
}
}

View File

@@ -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();

View File

@@ -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";
}
}

View File

@@ -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<Branch> 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")
)
);
}
}

View File

@@ -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(() => {

View File

@@ -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<BranchCollection> => {
const queryClient = useQueryClient();
const link = requiredLink(repository, "branches");
return useQuery<BranchCollection, Error>(
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<BranchCollection>
export const useBranch = (repository: Repository, name: string): ApiResultWithFetching<Branch> => {
const link = requiredLink(repository, "branches");
return useQuery<Branch, Error>(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<Branch, Error>(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<Branch, Error, BranchCreation>(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<unknown, Error, Branch>(
(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<DefaultBranch> => {
const link = requiredLink(repository, "defaultBranch");
return useQuery<DefaultBranch, Error>(branchQueryKey(repository, "__default-branch"), () =>
apiClient.get(link).then((response) => response.json())
apiClient.get(link).then(response => response.json())
);
};

View File

@@ -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(() => {

View File

@@ -61,8 +61,8 @@ const BranchSelector: FC<Props> = ({ branches, onSelectBranch, selectedBranch, l
<MinWidthControl className="control">
<Select
className="is-fullwidth"
options={branches.map((b) => ({ label: b.name, value: b.name }))}
onChange={(branch) => onSelectBranch(branches.filter((b) => b.name === branch)[0])}
options={branches.map(b => ({ label: b.name, value: b.name }))}
onChange={branch => onSelectBranch(branches.filter(b => b.name === branch)[0])}
disabled={!!disabled}
value={selectedBranch}
addValueToOptions={true}

View File

@@ -22,7 +22,8 @@
* SOFTWARE.
*/
import { Embedded, HalRepresentationWithEmbedded, Links } from "./hal";
import { Person } from ".";
import { Embedded, HalRepresentation, HalRepresentationWithEmbedded, Links } from "./hal";
type EmbeddedBranches = {
branches: Branch[];
@@ -35,10 +36,23 @@ export type Branch = {
revision: string;
defaultBranch?: boolean;
lastCommitDate?: string;
lastCommitter?: Person;
stale?: boolean;
_links: Links;
};
export type BranchDetails = HalRepresentation & {
branchName: string;
changesetsAhead?: number;
changesetsBehind?: number;
};
type EmbeddedBranchDetails = {
branchDetails: BranchDetails[];
} & Embedded;
export type BranchDetailsCollection = HalRepresentationWithEmbedded<EmbeddedBranchDetails>;
export type BranchCreation = {
name: string;
parent: string;

View File

@@ -127,7 +127,8 @@
"active": "Aktive Branches",
"stale": "Stale Branches"
},
"lastCommit": "Letzter Commit"
"lastCommit": "Letzter Commit",
"lastCommitter": "von {{name}}"
},
"create": {
"title": "Branch erstellen",
@@ -142,6 +143,9 @@
"sources": "Sources",
"defaultTag": "Default",
"dangerZone": "Branch löschen",
"aheadBehind": {
"tooltip": "{{ahead}} Commit(s) vor, {{behind}} Commit(s) hinter dem Default Branch"
},
"delete": {
"button": "Branch löschen",
"subtitle": "Branch löschen",

View File

@@ -127,7 +127,8 @@
"active": "Active Branches",
"stale": "Stale Branches"
},
"lastCommit": "Last commit"
"lastCommit": "Last commit",
"lastCommitter": "by {{name}}"
},
"create": {
"title": "Create Branch",
@@ -142,6 +143,9 @@
"sources": "Sources",
"defaultTag": "Default",
"dangerZone": "Delete Branch",
"aheadBehind": {
"tooltip": "{{ahead}} commit(s) ahead, {{behind}} commit(s) behind default branch"
},
"delete": {
"button": "Delete Branch",
"subtitle": "Delete Branch",

View File

@@ -0,0 +1,106 @@
/*
* 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.
*/
import { Branch, BranchDetails } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Tooltip } from "@scm-manager/ui-components";
import { calculateBarLength } from "./aheadBehind";
type Props = {
branch: Branch;
details: BranchDetails;
};
type BarProps = {
width: number;
direction: "right" | "left";
};
const Ahead = styled.div`
border-left: 1px solid gray;
`;
const Behind = styled.div``;
const Count = styled.div`
word-break: keep-all;
`;
const Bar = styled.span.attrs<BarProps>(props => ({
style: {
width: props.width + "%",
borderRadius: props.direction === "left" ? "25px 0 0 25px" : "0 25px 25px 0"
}
}))<BarProps>`
height: 3px;
max-width: 100%;
margin-top: -2px;
margin-bottom: 2px;
`;
const TooltipWithDefaultCursor = styled(Tooltip)`
cursor: default !important;
`;
const AheadBehindTag: FC<Props> = ({ branch, details }) => {
const [t] = useTranslation("repos");
if (
branch.defaultBranch ||
typeof details.changesetsBehind !== "number" ||
typeof details.changesetsAhead !== "number"
) {
return null;
}
return (
<TooltipWithDefaultCursor
message={t("branch.aheadBehind.tooltip", { ahead: details.changesetsAhead, behind: details.changesetsBehind })}
location="top"
>
<div className="columns is-flex is-unselectable is-hidden-mobile">
<Behind className="column is-half is-flex is-flex-direction-column is-align-items-flex-end p-0">
<Count className="is-size-7 pr-1">{details.changesetsBehind}</Count>
<Bar
className="has-rounded-border-left has-background-grey"
width={calculateBarLength(details.changesetsBehind)}
direction="left"
/>
</Behind>
<Ahead className="column is-half is-flex is-flex-direction-column is-align-items-flex-start p-0">
<Count className="is-size-7 pl-1">{details.changesetsAhead}</Count>
<Bar
className="has-rounded-border-right has-background-grey"
width={calculateBarLength(details.changesetsAhead)}
direction="right"
/>
</Ahead>
</div>
</TooltipWithDefaultCursor>
);
};
export default AheadBehindTag;

View File

@@ -25,42 +25,80 @@ import React, { FC } from "react";
import { Link as ReactLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { Branch, Link } from "@scm-manager/ui-types";
import { Branch, BranchDetails, Link } from "@scm-manager/ui-types";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
import DefaultBranchTag from "./DefaultBranchTag";
import AheadBehindTag from "./AheadBehindTag";
type Props = {
baseUrl: string;
branch: Branch;
onDelete: (branch: Branch) => void;
details?: BranchDetails;
};
const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete, details }) => {
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
const [t] = useTranslation("repos");
let deleteButton;
if ((branch?._links?.delete as Link)?.href) {
deleteButton = (
<span className="icon is-small is-hovered" onClick={() => onDelete(branch)} onKeyDown={(e) => e.key === "Enter" && onDelete(branch)} tabIndex={0}>
<span
className="icon is-small is-hovered is-clickable"
onClick={() => onDelete(branch)}
onKeyDown={e => e.key === "Enter" && onDelete(branch)}
tabIndex={0}
>
<Icon name="trash" className="fas " title={t("branch.delete.button")} />
</span>
);
}
const renderBranchTag = () => {
if (branch.defaultBranch) {
return <DefaultBranchTag defaultBranch={branch.defaultBranch} />;
}
if (details) {
return <AheadBehindTag branch={branch} details={details} />;
}
return (
<div className="loader-wrapper">
<div className="loader is-loading" />
</div>
);
};
const committedAt = (
<>
{t("branches.table.lastCommit")} <DateFromNow date={branch.lastCommitDate} />
</>
);
let committedAtBy;
if (branch.lastCommitter?.name) {
committedAtBy = (
<>
{committedAt} {t("branches.table.lastCommitter", { name: branch.lastCommitter?.name })}
</>
);
} else {
committedAtBy = committedAt;
}
return (
<tr>
<td>
<td className="is-flex">
<ReactLink to={to} title={branch.name}>
{branch.name}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</ReactLink>
{branch.lastCommitDate && (
<span className={classNames("has-text-grey", "is-ellipsis-overflow", "is-size-7", "ml-4")}>
{t("branches.table.lastCommit")} <DateFromNow date={branch.lastCommitDate} />
{committedAtBy}
</span>
)}
</td>
<td className="has-text-centered">{renderBranchTag()}</td>
<td className="is-darker has-text-centered">{deleteButton}</td>
</tr>
);

View File

@@ -24,7 +24,7 @@
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BranchRow from "./BranchRow";
import { Branch, Repository } from "@scm-manager/ui-types";
import { Branch, BranchDetails, Repository } from "@scm-manager/ui-types";
import { ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { useDeleteBranch } from "@scm-manager/ui-api";
@@ -33,9 +33,10 @@ type Props = {
repository: Repository;
branches: Branch[];
type: string;
branchesDetails: BranchDetails[];
};
const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type, branchesDetails }) => {
const { isLoading, error, remove, isDeleted } = useDeleteBranch(repository);
const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
@@ -77,17 +78,17 @@ const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
isLoading,
onClick: () => deleteBranch(),
onClick: () => deleteBranch()
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => abortDelete(),
},
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
) : null}
{error ? <ErrorNotification error={error} /> : null}
<ErrorNotification error={error} />
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
@@ -95,8 +96,14 @@ const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
</tr>
</thead>
<tbody>
{(branches || []).map((branch) => (
<BranchRow key={branch.name} baseUrl={baseUrl} branch={branch} onDelete={onDelete} />
{(branches || []).map(branch => (
<BranchRow
key={branch.name}
baseUrl={baseUrl}
branch={branch}
onDelete={onDelete}
details={branchesDetails?.filter((b: BranchDetails) => b.branchName === branch.name)[0]}
/>
))}
</tbody>
</table>

View File

@@ -24,17 +24,22 @@
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Tag } from "@scm-manager/ui-components";
import styled from "styled-components";
type Props = WithTranslation & {
defaultBranch?: boolean;
};
const DefaultTag = styled(Tag)`
max-height: 1.5em;
`;
class DefaultBranchTag extends React.Component<Props> {
render() {
const { defaultBranch, t } = this.props;
if (defaultBranch) {
return <Tag className="ml-3" color="dark" label={t("branch.defaultTag")} />;
return <DefaultTag className="is-unselectable" color="dark" label={t("branch.defaultTag")} />;
}
return null;
}

View File

@@ -0,0 +1,46 @@
/*
* 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.
*/
import { calculateBarLength } from "./aheadBehind";
describe("ahead/behind percentage", () => {
it("0 should have percentage value of 5", () => {
const percentage = calculateBarLength(0);
expect(percentage).toEqual(5);
});
let lastPercentage = 5;
for (let changesets = 1; changesets < 4000; changesets++) {
it(`${changesets} should have percentage value less or equal to last value`, () => {
const percentage = calculateBarLength(changesets);
expect(percentage).toBeGreaterThanOrEqual(lastPercentage);
lastPercentage = percentage;
});
}
it("10000 should not have percentage value bigger than 100", () => {
const percentage = calculateBarLength(10000);
expect(percentage).toEqual(100);
});
});

View File

@@ -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.
*/
export const calculateBarLength = (changesets: number) => {
if (changesets <= 10) {
return changesets + 5;
} else if (changesets <= 50) {
return (changesets - 10) / 5 + 15;
} else if (changesets <= 500) {
return (changesets - 50) / 10 + 23;
} else if (changesets <= 3700) {
return (changesets - 500) / 100 + 68;
} else {
return 100;
}
};

View File

@@ -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.
*/
import React, { FC } from "react";
import { Branch, HalRepresentation, Repository } from "@scm-manager/ui-types";
import { CreateButton, ErrorNotification, Notification, Subtitle } from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
import BranchTable from "../components/BranchTable";
import { useTranslation } from "react-i18next";
import { useBranchDetailsCollection } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
baseUrl: string;
data: HalRepresentation;
};
const BranchTableWrapper: FC<Props> = ({ repository, baseUrl, data }) => {
const [t] = useTranslation("repos");
const branches: Branch[] = (data?._embedded?.branches as Branch[]) || [];
orderBranches(branches);
const staleBranches = branches.filter(b => b.stale);
const activeBranches = branches.filter(b => !b.stale);
const { error, data: branchesDetails } = useBranchDetailsCollection(repository, [
...activeBranches,
...staleBranches
]);
if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
const showCreateButton = !!data._links.create;
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
<ErrorNotification error={error} />
{activeBranches.length > 0 ? (
<BranchTable
repository={repository}
baseUrl={baseUrl}
type="active"
branches={activeBranches}
branchesDetails={branchesDetails}
/>
) : null}
{staleBranches.length > 0 ? (
<BranchTable
repository={repository}
baseUrl={baseUrl}
type="stale"
branches={staleBranches}
branchesDetails={branchesDetails}
/>
) : null}
{showCreateButton ? <CreateButton label={t("branches.overview.createButton")} link="./create" /> : null}
</>
);
};
export default BranchTableWrapper;

View File

@@ -22,12 +22,10 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { CreateButton, ErrorNotification, Loading, Notification, Subtitle } from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
import BranchTable from "../components/BranchTable";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import { useBranches } from "@scm-manager/ui-api";
import BranchTableWrapper from "./BranchTableWrapper";
type Props = {
repository: Repository;
@@ -36,7 +34,6 @@ type Props = {
const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
const { isLoading, error, data } = useBranches(repository);
const [t] = useTranslation("repos");
if (error) {
return <ErrorNotification error={error} />;
@@ -46,30 +43,7 @@ const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />;
}
const branches = data?._embedded?.branches || [];
if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
orderBranches(branches);
const staleBranches = branches.filter((b) => b.stale);
const activeBranches = branches.filter((b) => !b.stale);
const showCreateButton = !!data._links.create;
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
{activeBranches.length > 0 ? (
<BranchTable repository={repository} baseUrl={baseUrl} type="active" branches={activeBranches} />
) : null}
{staleBranches.length > 0 ? (
<BranchTable repository={repository} baseUrl={baseUrl} type="stale" branches={staleBranches} />
) : null}
{showCreateButton ? <CreateButton label={t("branches.overview.createButton")} link="./create" /> : null}
</>
);
return <BranchTableWrapper repository={repository} baseUrl={baseUrl} data={data} />;
};
export default BranchesOverview;

View File

@@ -41,7 +41,12 @@ const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
let deleteButton;
if ((tag?._links?.delete as Link)?.href) {
deleteButton = (
<span className="icon is-small" onClick={() => onDelete(tag)} onKeyDown={(e) => e.key === "Enter" && onDelete(tag)} tabIndex={0}>
<span
className="icon is-small is-clickable"
onClick={() => onDelete(tag)}
onKeyDown={e => e.key === "Enter" && onDelete(tag)}
tabIndex={0}
>
<Icon name="trash" className="fas" title={t("tag.delete.button")} />
</span>
);

View File

@@ -0,0 +1,54 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
public class BranchDetailsDto extends HalRepresentation {
private String branchName;
@JsonInclude(NON_NULL)
private Integer changesetsAhead;
@JsonInclude(NON_NULL)
private Integer changesetsBehind;
BranchDetailsDto(Links links, Embedded embedded) {
super(links, embedded);
}
}

View File

@@ -0,0 +1,77 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.google.common.annotations.VisibleForTesting;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.BranchDetailsCommandResult;
import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import java.util.Optional;
@Mapper
public abstract class BranchDetailsMapper extends BaseMapper<BranchDetailsCommandResult, BranchDetailsDto> {
@Inject
private ResourceLinks resourceLinks;
abstract BranchDetailsDto map(@Context Repository repository, String branchName, BranchDetailsCommandResult result);
@ObjectFactory
BranchDetailsDto createDto(@Context Repository repository, String branchName) {
Links.Builder linksBuilder = createLinks(repository, branchName);
Embedded.Builder embeddedBuilder = Embedded.embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository);
return new BranchDetailsDto(linksBuilder.build(), embeddedBuilder.build());
}
Integer map(Optional<Integer> o) {
return o.orElse(null);
}
private Links.Builder createLinks(@Context Repository repository, String branch) {
return Links.linkingTo()
.self(
resourceLinks.branchDetails()
.self(
repository.getNamespace(),
repository.getName(),
branch)
);
}
@VisibleForTesting
void setResourceLinks(ResourceLinks resourceLinks) {
this.resourceLinks = resourceLinks;
}
}

View File

@@ -0,0 +1,201 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.hibernate.validator.constraints.Length;
import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.BranchDetailsCommandBuilder;
import sonia.scm.repository.api.BranchDetailsCommandResult;
import sonia.scm.repository.api.CommandNotSupportedException;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.validation.constraints.Pattern;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import static sonia.scm.repository.Branch.VALID_BRANCH_NAMES;
@Path("")
public class BranchDetailsResource {
private final RepositoryServiceFactory serviceFactory;
private final BranchDetailsMapper mapper;
private final ResourceLinks resourceLinks;
@Inject
public BranchDetailsResource(RepositoryServiceFactory serviceFactory, BranchDetailsMapper mapper, ResourceLinks resourceLinks) {
this.serviceFactory = serviceFactory;
this.mapper = mapper;
this.resourceLinks = resourceLinks;
}
/**
* Returns branch details for given branch.
*
* <strong>Note:</strong> This method requires "repository" privilege.
*
* @param namespace the namespace of the repository
* @param name the name of the repository
* @param branchName the name of the branch
*/
@GET
@Path("{branch}")
@Produces(VndMediaType.BRANCH_DETAILS)
@Operation(summary = "Get single branch details", description = "Returns details of a single branch.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.BRANCH_DETAILS,
schema = @Schema(implementation = BranchDetailsDto.class)
)
)
@ApiResponse(responseCode = "400", description = "branches not supported for given repository")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the branch")
@ApiResponse(
responseCode = "404",
description = "not found, no branch with the specified name for the repository available or repository found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getBranchDetails(
@PathParam("namespace") String namespace,
@PathParam("name") String name,
@Length(min = 1, max = 100) @Pattern(regexp = VALID_BRANCH_NAMES) @PathParam("branch") String branchName
) {
try (RepositoryService service = serviceFactory.create(new NamespaceAndName(namespace, name))) {
BranchDetailsCommandResult result = service.getBranchDetailsCommand().execute(branchName);
BranchDetailsDto dto = mapper.map(service.getRepository(), branchName, result);
return Response.ok(dto).build();
} catch (CommandNotSupportedException ex) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
/**
* Returns branch details for given branches.
*
* <strong>Note:</strong> This method requires "repository" privilege.
*
* @param namespace the namespace of the repository
* @param name the name of the repository
* @param branches a comma-seperated list of branches
*/
@GET
@Path("")
@Produces(VndMediaType.BRANCH_DETAILS_COLLECTION)
@Operation(summary = "Get multiple branch details", description = "Returns a collection of branch details.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.BRANCH_DETAILS_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "400", description = "branches not supported for given repository")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the branch")
@ApiResponse(
responseCode = "404",
description = "not found, no branch with the specified name for the repository available or repository found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getBranchDetailsCollection(
@PathParam("namespace") String namespace,
@PathParam("name") String name,
@QueryParam("branches") List<@Length(min = 1, max = 100) @Pattern(regexp = VALID_BRANCH_NAMES) String> branches
) {
try (RepositoryService service = serviceFactory.create(new NamespaceAndName(namespace, name))) {
List<BranchDetailsDto> dtos = getBranchDetailsDtos(service, decodeBranchNames(branches));
Links links = Links.linkingTo().self(resourceLinks.branchDetailsCollection().self(namespace, name)).build();
Embedded embedded = Embedded.embeddedBuilder().with("branchDetails", dtos).build();
return Response.ok(new HalRepresentation(links, embedded)).build();
} catch (CommandNotSupportedException ex) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
private List<BranchDetailsDto> getBranchDetailsDtos(RepositoryService service, Collection<String> branches) {
List<BranchDetailsDto> dtos = new ArrayList<>();
if (!branches.isEmpty()) {
BranchDetailsCommandBuilder branchDetailsCommand = service.getBranchDetailsCommand();
for (String branch : branches) {
try {
BranchDetailsCommandResult result = branchDetailsCommand.execute(branch);
dtos.add(mapper.map(service.getRepository(), branch, result));
} catch (NotFoundException e) {
// we simply omit details for branches that do not exist
}
}
}
return dtos;
}
private Collection<String> decodeBranchNames(Collection<String> branches) {
return branches.stream().map(HttpUtil::decode).collect(Collectors.toList());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,15 @@
expireAfterWrite="5400"
/>
<!--
Search cache for branch details
-->
<cache
name="sonia.cache.cmd.branch-details"
maximumSize="10000"
expireAfterAccess="60000"
/>
<!--
Search cache for groups
average: 0.5K

View File

@@ -0,0 +1,64 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.api.BranchDetailsCommandResult;
import java.net.URI;
import static org.assertj.core.api.Assertions.assertThat;
class BranchDetailsMapperTest {
private final Repository repository = RepositoryTestData.create42Puzzle();
BranchDetailsMapper mapper = new BranchDetailsMapperImpl();
@BeforeEach
void configureMapper() {
ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore();
scmPathInfoStore.set(() -> URI.create("/scm/api/"));
mapper.setResourceLinks(new ResourceLinks(scmPathInfoStore));
}
@Test
void shouldMapDto() {
BranchDetailsDto dto = mapper.map(
repository,
"master",
new BranchDetailsCommandResult(42, 21)
);
assertThat(dto.getBranchName()).isEqualTo("master");
assertThat(dto.getChangesetsAhead()).isEqualTo(42);
assertThat(dto.getChangesetsBehind()).isEqualTo(21);
assertThat(dto.getLinks().getLinkBy("self").get().getHref())
.isEqualTo("/scm/api/v2/repositories/hitchhiker/42Puzzle/branch-details/master");
}
}

View File

@@ -0,0 +1,185 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.NotFoundException;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.api.BranchDetailsCommandBuilder;
import sonia.scm.repository.api.BranchDetailsCommandResult;
import sonia.scm.repository.api.CommandNotSupportedException;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.web.JsonMockHttpResponse;
import sonia.scm.web.RestDispatcher;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BranchDetailsResourceTest extends RepositoryTestBase {
private final RestDispatcher dispatcher = new RestDispatcher();
private final Repository repository = RepositoryTestData.create42Puzzle();
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@Mock
private RepositoryServiceFactory serviceFactory;
@Mock
private RepositoryService service;
@Mock
private BranchDetailsCommandBuilder branchDetailsCommandBuilder;
private final BranchDetailsMapperImpl mapper = new BranchDetailsMapperImpl();
private final JsonMockHttpResponse response = new JsonMockHttpResponse();
@BeforeEach
void prepareEnvironment() {
super.branchDetailsResource = new BranchDetailsResource(serviceFactory, mapper, resourceLinks);
dispatcher.addSingletonResource(getRepositoryRootResource());
ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore();
scmPathInfoStore.set(() -> URI.create("/scm/api/"));
mapper.setResourceLinks(new ResourceLinks(scmPathInfoStore));
}
@Test
void shouldReturnBadRequestIfBranchDetailsNotSupported() throws URISyntaxException {
when(serviceFactory.create(repository.getNamespaceAndName())).thenReturn(service);
doThrow(CommandNotSupportedException.class).when(service).getBranchDetailsCommand();
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + "/branch-details/master/");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
void shouldReturnBranchDetails() throws URISyntaxException, UnsupportedEncodingException {
when(serviceFactory.create(repository.getNamespaceAndName())).thenReturn(service);
when(service.getRepository()).thenReturn(repository);
when(service.getBranchDetailsCommand()).thenReturn(branchDetailsCommandBuilder);
BranchDetailsCommandResult result = new BranchDetailsCommandResult(42, 21);
when(branchDetailsCommandBuilder.execute("master")).thenReturn(result);
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + "/branch-details/master/");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString())
.isEqualTo("{\"branchName\":\"master\",\"changesetsAhead\":42,\"changesetsBehind\":21,\"_links\":{\"self\":{\"href\":\"/scm/api/v2/repositories/hitchhiker/42Puzzle/branch-details/master\"}}}");
}
@ParameterizedTest
@ValueSource(strings = {
"%2Fmaster",
"1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890X"
})
void shouldValidateSingleBranch(String branchName) throws URISyntaxException {
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + String.format("/branch-details/%s/", branchName));
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
void shouldGetEmptyDetailsCollection() throws URISyntaxException, UnsupportedEncodingException {
when(serviceFactory.create(repository.getNamespaceAndName())).thenReturn(service);
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + "/branch-details/");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString()).isEqualTo("{\"_links\":{\"self\":{\"href\":\"/v2/repositories/hitchhiker/42Puzzle/branch-details/\"}},\"_embedded\":{\"branchDetails\":[]}}");
}
@Test
void shouldGetDetailsCollection() throws URISyntaxException, UnsupportedEncodingException {
when(serviceFactory.create(repository.getNamespaceAndName())).thenReturn(service);
when(service.getRepository()).thenReturn(repository);
when(service.getBranchDetailsCommand()).thenReturn(branchDetailsCommandBuilder);
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + "/branch-details?branches=master&branches=develop&branches=feature%2Fhitchhiker42");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString()).contains("{\"branchDetails\":[{\"branchName\":\"master\",\"_links\":{\"self\":{\"href\":\"/scm/api/v2/repositories/hitchhiker/42Puzzle/branch-details/master\"}}}");
assertThat(response.getContentAsString()).contains("{\"branchName\":\"develop\",\"_links\":{\"self\":{\"href\":\"/scm/api/v2/repositories/hitchhiker/42Puzzle/branch-details/develop\"}}}");
assertThat(response.getContentAsString()).contains("{\"branchName\":\"feature/hitchhiker42\",\"_links\":{\"self\":{\"href\":\"/scm/api/v2/repositories/hitchhiker/42Puzzle/branch-details/feature%2Fhitchhiker42\"}}}");
}
@ParameterizedTest
@ValueSource(strings = {
"%2Fmaster",
"",
"1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890X"
})
void shouldRejectInvalidBranchInCollection(String branchName) throws URISyntaxException {
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + String.format("/branch-details?branches=ok&branches=%s", branchName));
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
void shouldIgnoreMissingBranchesInCollection() throws URISyntaxException {
when(serviceFactory.create(repository.getNamespaceAndName())).thenReturn(service);
when(service.getBranchDetailsCommand()).thenReturn(branchDetailsCommandBuilder);
when(branchDetailsCommandBuilder.execute("no-such-branch")).thenThrow(NotFoundException.class);
MockHttpRequest request = MockHttpRequest
.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + repository.getNamespaceAndName() + "/branch-details?branches=no-such-branch");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsJson().get("_embedded").get("branchDetails")).isEmpty();
}
}

View File

@@ -36,6 +36,7 @@ abstract class RepositoryTestBase {
RepositoryManager manager;
TagRootResource tagRootResource;
BranchRootResource branchRootResource;
BranchDetailsResource branchDetailsResource;
ChangesetRootResource changesetRootResource;
SourceRootResource sourceRootResource;
ContentResource contentResource;
@@ -55,6 +56,7 @@ abstract class RepositoryTestBase {
RepositoryBasedResourceProvider repositoryBasedResourceProvider = new RepositoryBasedResourceProvider(
of(tagRootResource),
of(branchRootResource),
of(branchDetailsResource),
of(changesetRootResource),
of(sourceRootResource),
of(contentResource),

View File

@@ -336,6 +336,14 @@ public class RepositoryToRepositoryDtoMapperTest {
assertTrue(dto.isHealthCheckRunning());
}
@Test
public void shouldAppendBranchDetailsLinkIfSupported() {
Repository testRepository = createTestRepository();
when(repositoryService.isSupported(Command.BRANCH_DETAILS)).thenReturn(true);
RepositoryDto dto = mapper.map(testRepository);
assertTrue(dto.getLinks().getLinkBy("branchDetailsCollection").isPresent());
}
@Test
public void shouldCreateCorrectLinksForHealthChecks() {
when(scmContextProvider.getDocumentationVersion()).thenReturn("2.17.x");

View File

@@ -83,6 +83,8 @@ public class ResourceLinksMock {
lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo));
lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo));
lenient().when(resourceLinks.search()).thenReturn(new ResourceLinks.SearchLinks(pathInfo));
lenient().when(resourceLinks.branchDetails()).thenReturn(new ResourceLinks.BranchDetailsLinks(pathInfo));
lenient().when(resourceLinks.branchDetailsCollection()).thenReturn(new ResourceLinks.BranchDetailsCollectionLinks(pathInfo));
return resourceLinks;
}