mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-03 12:05:52 +01:00
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:
Binary file not shown.
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 84 KiB |
@@ -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 |
@@ -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.
|
||||
|
||||
2
gradle/changelog/branch_details.yaml
Normal file
2
gradle/changelog/branch_details.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Show additional information on branches overview ([#1876](https://github.com/scm-manager/scm-manager/pull/1876))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,5 +82,10 @@ public enum Command
|
||||
/**
|
||||
* @since 2.26.0
|
||||
*/
|
||||
FILE_LOCK
|
||||
FILE_LOCK,
|
||||
|
||||
/**
|
||||
* @since 2.28.0
|
||||
*/
|
||||
BRANCH_DETAILS
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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(() => {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user