Merge branch 'develop' into feature/hg_hooks_over_tcp

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sebastian Sdorra
2020-11-27 08:57:09 +01:00
68 changed files with 1134 additions and 401 deletions

View File

@@ -6,9 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Add tooltips to short links on repository overview ([#1441](https://github.com/scm-manager/scm-manager/pull/1441))
- Show the date of the last commit for branches in the frontend ([#1439](https://github.com/scm-manager/scm-manager/pull/1439))
- Unify and add description to key view across user settings ([#1440](https://github.com/scm-manager/scm-manager/pull/1440))
### Changed
- Send mercurial hook callbacks over separate tcp socket instead of http ([#1416](https://github.com/scm-manager/scm-manager/pull/1416))
## [2.10.1] - 2020-11-24
### Fixed
- Improved logging of failures during plugin installation ([#1442](https://github.com/scm-manager/scm-manager/pull/1442))
- Do not throw exception when plugin file does not exist on cancelled installation ([#1442](https://github.com/scm-manager/scm-manager/pull/1442))
## [2.10.0] - 2020-11-20
### Added
- Delete branches directly in the UI ([#1422](https://github.com/scm-manager/scm-manager/pull/1422))
@@ -419,3 +430,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[2.6.3]: https://www.scm-manager.org/download/2.6.3
[2.7.0]: https://www.scm-manager.org/download/2.7.0
[2.7.1]: https://www.scm-manager.org/download/2.7.1
[2.8.0]: https://www.scm-manager.org/download/2.8.0
[2.9.0]: https://www.scm-manager.org/download/2.9.0
[2.9.1]: https://www.scm-manager.org/download/2.9.1
[2.10.0]: https://www.scm-manager.org/download/2.10.0
[2.10.1]: https://www.scm-manager.org/download/2.10.1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 285 KiB

View File

@@ -13,7 +13,7 @@ eingegeben werden. Danach muss das neue Passwort zweimal eingegeben werden.
## Öffentliche Schlüssel
Zum Prüfen von Signaturen für z. B. Commits können hier die entsprechenden öffentlichen Schlüssel hinterlegt werden.
Zum Prüfen von Signaturen für z. B. Commits können hier die entsprechenden öffentlichen GPG Schlüssel hinterlegt werden.
Zudem können hier die vom SCM-Manager erstellten Signaturschlüssel heruntergeladen werden.
## API Schlüssel

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 270 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -11,9 +11,9 @@ Here the password for the current account can be changed when it is a local acco
external system). To authorize the change, the current password has to be put first. Then the new password has to be
entered twice.
## Öffentliche Schlüssel
## Public Keys
To check signatures for example for commits, public keys can be stored here. Additionally the keys created by
To check signatures (for example for commits), gpg public keys can be stored here. Additionally the keys created by
SCM-Manager can be accessed here, too.
## API keys

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 269 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 263 KiB

View File

@@ -580,7 +580,7 @@
<plugin>
<groupId>sonia.scm.maven</groupId>
<artifactId>smp-maven-plugin</artifactId>
<version>1.3.0</version>
<version>1.4.0</version>
</plugin>
<plugin>
@@ -903,7 +903,7 @@
<properties>
<!-- test libraries -->
<mockito.version>3.5.15</mockito.version>
<mockito.version>3.6.0</mockito.version>
<hamcrest.version>2.1</hamcrest.version>
<junit.version>5.7.0</junit.version>
@@ -937,7 +937,7 @@
<svnkit.version>1.10.1-scm2</svnkit.version>
<!-- util libraries -->
<guava.version>26.0-jre</guava.version>
<guava.version>30.0-jre</guava.version>
<!-- frontend -->
<nodejs.version>12.16.1</nodejs.version>

View File

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

View File

@@ -0,0 +1,65 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import sonia.scm.repository.Branch;
import sonia.scm.repository.spi.BranchStaleComputer;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static java.time.Instant.ofEpochMilli;
public class BranchXDaysOlderThanDefaultStaleComputer implements BranchStaleComputer {
public static final int DEFAULT_AMOUNT_OF_DAYS = 30;
private final int amountOfDays;
public BranchXDaysOlderThanDefaultStaleComputer() {
this(DEFAULT_AMOUNT_OF_DAYS);
}
public BranchXDaysOlderThanDefaultStaleComputer(int amountOfDays) {
this.amountOfDays = amountOfDays;
}
@Override
@SuppressWarnings("java:S3655") // we check "isPresent" for both dates, but due to the third check sonar does not get it
public boolean computeStale(Branch branch, StaleContext context) {
Branch defaultBranch = context.getDefaultBranch();
if (shouldCompute(branch, defaultBranch)) {
Instant defaultCommitDate = ofEpochMilli(defaultBranch.getLastCommitDate().get());
Instant thisCommitDate = ofEpochMilli(branch.getLastCommitDate().get());
return thisCommitDate.plus(amountOfDays, ChronoUnit.DAYS).isBefore(defaultCommitDate);
} else {
return false;
}
}
public boolean shouldCompute(Branch branch, Branch defaultBranch) {
return !branch.isDefaultBranch() && branch.getLastCommitDate().isPresent() && defaultBranch.getLastCommitDate().isPresent();
}
}

View File

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

View File

@@ -0,0 +1,38 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import lombok.Data;
import sonia.scm.repository.Branch;
public interface BranchStaleComputer {
boolean computeStale(Branch branch, StaleContext context);
@Data
class StaleContext {
private Branch defaultBranch;
}
}

View File

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

View File

@@ -0,0 +1,92 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.api;
import org.junit.jupiter.api.Test;
import sonia.scm.repository.Branch;
import sonia.scm.repository.spi.BranchStaleComputer;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static java.time.Instant.now;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.repository.Branch.defaultBranch;
import static sonia.scm.repository.Branch.normalBranch;
class BranchXDaysOlderThanDefaultStaleComputerTest {
Instant now = now();
BranchXDaysOlderThanDefaultStaleComputer computer = new BranchXDaysOlderThanDefaultStaleComputer(30);
@Test
void shouldTagOldBranchAsStale() {
long staleTime =
now
.minus(30, ChronoUnit.DAYS)
.minus(1, ChronoUnit.MINUTES)
.toEpochMilli();
Branch branch = normalBranch("hog", "42", staleTime);
boolean stale = computer.computeStale(branch, createStaleContext());
assertThat(stale).isTrue();
}
@Test
void shouldNotTagNotSoOldBranchAsStale() {
long activeTime =
now
.minus(30, ChronoUnit.DAYS)
.plus(1, ChronoUnit.MINUTES)
.toEpochMilli();
Branch branch = normalBranch("hog", "42", activeTime);
boolean stale = computer.computeStale(branch, createStaleContext());
assertThat(stale).isFalse();
}
@Test
void shouldNotTagDefaultBranchAsStale() {
long staleTime =
now
.minus(30, ChronoUnit.DAYS)
.minus(1, ChronoUnit.MINUTES)
.toEpochMilli();
Branch branch = defaultBranch("hog", "42", staleTime);
boolean stale = computer.computeStale(branch, createStaleContext());
assertThat(stale).isFalse();
}
BranchStaleComputer.StaleContext createStaleContext() {
BranchStaleComputer.StaleContext staleContext = new BranchStaleComputer.StaleContext();
staleContext.setDefaultBranch(defaultBranch("default", "23", now.toEpochMilli()));
return staleContext;
}
}

View File

@@ -0,0 +1,74 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import sonia.scm.repository.Branch;
import sonia.scm.repository.api.BranchXDaysOlderThanDefaultStaleComputer;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static java.time.Instant.now;
import static java.util.Arrays.asList;
class BranchesCommandTest {
@Test
void shouldMarkEachBranchDependingOnDefaultBranch() throws IOException {
Instant now = now();
long staleTime =
now
.minus(30, ChronoUnit.DAYS)
.minus(1, ChronoUnit.MINUTES)
.toEpochMilli();
long activeTime =
now
.minus(30, ChronoUnit.DAYS)
.plus(1, ChronoUnit.MINUTES)
.toEpochMilli();
List<Branch> branches = asList(
Branch.normalBranch("arthur", "42", staleTime),
Branch.normalBranch("marvin", "42", staleTime),
Branch.defaultBranch("hog", "42", now.toEpochMilli()),
Branch.normalBranch("trillian", "42", activeTime)
);
List<Branch> branchesWithStaleFlags = new BranchesCommand() {
@Override
public List<Branch> getBranches() {
return branches;
}
}.getBranchesWithStaleFlags(new BranchXDaysOlderThanDefaultStaleComputer());
Assertions.assertThat(branchesWithStaleFlags)
.extracting("stale")
.containsExactly(true, true, false, false);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.junit.Test;
import sonia.scm.repository.Branch;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.repository.Branch.defaultBranch;
import static sonia.scm.repository.Branch.normalBranch;
public class HgBranchesCommandTest extends AbstractHgCommandTestBase {
@Test
public void shouldReadBranches() {
HgBranchesCommand command = new HgBranchesCommand(cmdContext);
List<Branch> branches = command.getBranches();
assertThat(branches).contains(
defaultBranch("default", "2baab8e80280ef05a9aa76c49c76feca2872afb7", 1339586381000L),
normalBranch("test-branch", "79b6baf49711ae675568e0698d730b97ef13e84a", 1339586299000L)
);
}
}

View File

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

View File

@@ -21,13 +21,13 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { withRouter, RouteComponentProps } from "react-router-dom";
import React, { FC } from "react";
import { useHistory, useLocation } from "react-router-dom";
import classNames from "classnames";
import { Button, DropDown, urls } from "./index";
import { FilterInput } from "./forms";
type Props = RouteComponentProps & {
type Props = {
showCreateButton: boolean;
currentGroup: string;
groups: string[];
@@ -35,41 +35,33 @@ type Props = RouteComponentProps & {
groupSelected: (namespace: string) => void;
label?: string;
testId?: string;
searchPlaceholder?: string;
};
class OverviewPageActions extends React.Component<Props> {
render() {
const { history, currentGroup, groups, location, link, testId, groupSelected } = this.props;
const groupSelector = groups && (
<div className={"column is-flex"}>
<DropDown
className={"is-fullwidth"}
options={groups}
preselectedOption={currentGroup}
optionSelected={groupSelected}
/>
</div>
);
const OverviewPageActions: FC<Props> = ({
groups,
currentGroup,
showCreateButton,
link,
groupSelected,
label,
testId,
searchPlaceholder
}) => {
const history = useHistory();
const location = useLocation();
const groupSelector = groups && (
<div className={"column is-flex"}>
<DropDown
className={"is-fullwidth"}
options={groups}
preselectedOption={currentGroup}
optionSelected={groupSelected}
/>
</div>
);
return (
<div className={"columns is-tablet"}>
{groupSelector}
<div className={"column"}>
<FilterInput
value={urls.getQueryStringFromLocation(location)}
filter={filter => {
history.push(`/${link}/?q=${filter}`);
}}
testId={testId + "-filter"}
/>
</div>
{this.renderCreateButton()}
</div>
);
}
renderCreateButton() {
const { showCreateButton, link, label } = this.props;
const renderCreateButton = () => {
if (showCreateButton) {
return (
<div className={classNames("input-button", "control", "column")}>
@@ -78,7 +70,24 @@ class OverviewPageActions extends React.Component<Props> {
);
}
return null;
}
}
};
export default withRouter(OverviewPageActions);
return (
<div className={"columns is-tablet"}>
{groupSelector}
<div className={"column"}>
<FilterInput
placeholder={searchPlaceholder}
value={urls.getQueryStringFromLocation(location)}
filter={filter => {
history.push(`/${link}/?q=${filter}`);
}}
testId={testId + "-filter"}
/>
</div>
{renderCreateButton()}
</div>
);
};
export default OverviewPageActions;

View File

@@ -50439,36 +50439,70 @@ exports[`Storyshots RepositoryEntry Avatar EP 1`] = `
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div
@@ -50553,36 +50587,70 @@ exports[`Storyshots RepositoryEntry Before Title EP 1`] = `
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div
@@ -50664,36 +50732,70 @@ exports[`Storyshots RepositoryEntry Default 1`] = `
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div
@@ -50775,27 +50877,56 @@ exports[`Storyshots RepositoryEntry Quick Link EP 1`] = `
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="level-item"
@@ -50809,9 +50940,14 @@ exports[`Storyshots RepositoryEntry Quick Link EP 1`] = `
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div

View File

@@ -27,10 +27,11 @@ import { CardColumn, DateFromNow } from "@scm-manager/ui-components";
import RepositoryEntryLink from "./RepositoryEntryLink";
import RepositoryAvatar from "./RepositoryAvatar";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { withTranslation, WithTranslation } from "react-i18next";
type DateProp = Date | string;
type Props = {
type Props = WithTranslation & {
repository: Repository;
// @VisibleForTesting
// the baseDate is only to avoid failing snapshot tests
@@ -43,29 +44,67 @@ class RepositoryEntry extends React.Component<Props> {
};
renderBranchesLink = (repository: Repository, repositoryLink: string) => {
const { t } = this.props;
if (repository._links["branches"]) {
return <RepositoryEntryLink icon="code-branch" to={repositoryLink + "/branches/"} />;
return (
<RepositoryEntryLink
icon="code-branch"
to={repositoryLink + "/branches/"}
tooltip={t("repositoryRoot.tooltip.branches")}
/>
);
}
return null;
};
renderTagsLink = (repository: Repository, repositoryLink: string) => {
const { t } = this.props;
if (repository._links["tags"]) {
return (
<RepositoryEntryLink icon="tags" to={repositoryLink + "/tags/"} tooltip={t("repositoryRoot.tooltip.tags")} />
);
}
return null;
};
renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
const { t } = this.props;
if (repository._links["changesets"]) {
return <RepositoryEntryLink icon="exchange-alt" to={repositoryLink + "/code/changesets/"} />;
return (
<RepositoryEntryLink
icon="exchange-alt"
to={repositoryLink + "/code/changesets/"}
tooltip={t("repositoryRoot.tooltip.commits")}
/>
);
}
return null;
};
renderSourcesLink = (repository: Repository, repositoryLink: string) => {
const { t } = this.props;
if (repository._links["sources"]) {
return <RepositoryEntryLink icon="code" to={repositoryLink + "/code/sources/"} />;
return (
<RepositoryEntryLink
icon="code"
to={repositoryLink + "/code/sources/"}
tooltip={t("repositoryRoot.tooltip.sources")}
/>
);
}
return null;
};
renderModifyLink = (repository: Repository, repositoryLink: string) => {
const { t } = this.props;
if (repository._links["update"]) {
return <RepositoryEntryLink icon="cog" to={repositoryLink + "/settings/general"} />;
return (
<RepositoryEntryLink
icon="cog"
to={repositoryLink + "/settings/general"}
tooltip={t("repositoryRoot.tooltip.settings")}
/>
);
}
return null;
};
@@ -74,6 +113,7 @@ class RepositoryEntry extends React.Component<Props> {
return (
<>
{this.renderBranchesLink(repository, repositoryLink)}
{this.renderTagsLink(repository, repositoryLink)}
{this.renderChangesetsLink(repository, repositoryLink)}
{this.renderSourcesLink(repository, repositoryLink)}
<ExtensionPoint name={"repository.card.quickLink"} props={{ repository, repositoryLink }} renderAll={true} />
@@ -118,4 +158,4 @@ class RepositoryEntry extends React.Component<Props> {
}
}
export default RepositoryEntry;
export default withTranslation("repos")(RepositoryEntry);

View File

@@ -25,10 +25,12 @@ import React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { Icon } from "@scm-manager/ui-components";
import Tooltip from "../Tooltip";
type Props = {
to: string;
icon: string;
tooltip?: string;
};
const PointerEventsLink = styled(Link)`
@@ -37,10 +39,20 @@ const PointerEventsLink = styled(Link)`
class RepositoryEntryLink extends React.Component<Props> {
render() {
const { to, icon } = this.props;
const { to, icon, tooltip } = this.props;
let content = <Icon className="fa-lg" name={icon} color="inherit" />;
if (tooltip) {
content = (
<Tooltip message={tooltip} location="top">
{content}
</Tooltip>
);
}
return (
<PointerEventsLink className="level-item" to={to}>
<Icon className="fa-lg" name={icon} color="inherit" />
{content}
</PointerEventsLink>
);
}

View File

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

View File

@@ -25,6 +25,9 @@
"setPermissionsNavLink": "Berechtigungen"
}
},
"overview": {
"searchGroup": "Gruppe suchen"
},
"add-group": {
"title": "Gruppe erstellen",
"subtitle": "Erstellen einer neuen Gruppe"

View File

@@ -36,13 +36,22 @@
"settingsNavLink": "Einstellungen",
"generalNavLink": "Generell",
"permissionsNavLink": "Berechtigungen"
},
"tooltip": {
"branches": "Branches",
"tags": "Tags",
"commits": "Commits",
"sources": "Sources",
"settings": "Einstellungen"
}
},
"overview": {
"title": "Repositories",
"subtitle": "Übersicht aller verfügbaren Repositories",
"noRepositories": "Keine Repositories gefunden.",
"createButton": "Repository erstellen"
"createButton": "Repository erstellen",
"searchRepository": "Repository suchen",
"allNamespaces": "Alle Namespaces"
},
"create": {
"title": "Repository erstellen",
@@ -55,7 +64,11 @@
"createButton": "Branch erstellen"
},
"table": {
"branches": "Branches"
"branches": {
"active": "Aktive Branches",
"stale": "Alte Branches"
},
"lastCommit": "Letzter Commit"
},
"create": {
"title": "Branch erstellen",

View File

@@ -30,6 +30,9 @@
"noUsers": "Keine Benutzer gefunden.",
"createButton": "Benutzer erstellen"
},
"overview": {
"searchUser": "Benutzer suchen"
},
"singleUser": {
"errorTitle": "Fehler",
"errorSubtitle": "Unbekannter Benutzer Fehler",
@@ -76,15 +79,22 @@
}
},
"publicKey": {
"subtitle": "Öffentliche Schlüssel",
"description": "Zum Prüfen von Signaturen für z. B. Commits können hier die entsprechenden öffentlichen GPG Schlüssel hinterlegt werden. Zudem können hier die vom SCM-Manager erstellten Signaturschlüssel heruntergeladen werden.",
"noStoredKeys": "Es wurden keine Schlüssel gefunden.",
"displayName": "Anzeigename",
"raw": "Schlüssel",
"created": "Eingetragen an",
"addKey": "Schlüssel hinzufügen",
"raw": "Schlüssel",
"download": "Herunterladen",
"delete": "Löschen",
"download": "Herunterladen"
"addSubtitle": "Neuen Schlüssel hinzufügen",
"addKey": "Schlüssel hinzufügen"
},
"apiKey": {
"subtitle": "API Schlüssel",
"text1": "Erstelle und verwalte Personal Access Token um auf die REST API zuzugreifen oder diese als Passwort für SCM-Clients zu nutzen. Die Rechte der Token sind auf Repositories und die gewählte Rolle beschränkt.",
"manageRoles": "Sie können die Rollenberechtigungen in der Administration unter „Berechtigungsrollen“ einsehen und neue Rollen anlegen.",
"text2": "Um den Token in REST-Abfragen zu nutzen, übergeben Sie diesen als Cookie mit dem Namen „X-Bearer-Token“. Sie können den Token auch anstelle Ihres Passworts nutzen, um sich mit SCM-Clients anzumelden.",
"noStoredKeys": "Es wurden keine Schlüssel gefunden.",
"displayName": "Anzeigename",
"permissionRole": {
@@ -92,12 +102,10 @@
"help": "Mit der Rolle können Sie die Berechtigung für diesen Schlüssel einschränken"
},
"created": "Eingetragen an",
"addSubtitle": "Neuen Schlüssel hinzufügen",
"addKey": "Schlüssel hinzufügen",
"delete": "Löschen",
"download": "Herunterladen",
"text1": "Erstelle und verwalte Personal Access Token um auf die REST API zuzugreifen oder diese als Passwort für SCM-Clients zu nutzen. Die Rechte der Token sind auf Repositories und die gewählte Rolle beschränkt.",
"manageRoles": "Sie können die Rollenberechtigungen in der Administration unter „Berechtigungsrollen“ einsehen und neue Rollen anlegen.",
"text2": "Um den Token in REST-Abfragen zu nutzen, übergeben Sie diesen als Cookie mit dem Namen „X-Bearer-Token“. Sie können den Token auch anstelle Ihres Passworts nutzen, um sich mit SCM-Clients anzumelden.",
"modal": {
"title": "Schlüssel erzeugt",
"text1": "Ihr neuer API-Schlüssel ist bereit. Sie können diesen als Token für Zugriffe auf die REST-Schnittstelle nutzen oder anstelle Ihres Passworts zum Login mit SCM-Clients nutzen.",

View File

@@ -25,6 +25,9 @@
"setPermissionsNavLink": "Permissions"
}
},
"overview": {
"searchGroup": "Search group"
},
"add-group": {
"title": "Create Group",
"subtitle": "Create a new group"

View File

@@ -36,13 +36,22 @@
"settingsNavLink": "Settings",
"generalNavLink": "General",
"permissionsNavLink": "Permissions"
},
"tooltip": {
"branches": "Branches",
"tags": "Tags",
"commits": "Commits",
"sources": "Sources",
"settings": "Settings"
}
},
"overview": {
"title": "Repositories",
"subtitle": "Overview of available repositories",
"noRepositories": "No repositories found.",
"createButton": "Create Repository"
"createButton": "Create Repository",
"searchRepository": "Search repository",
"allNamespaces": "All namespaces"
},
"create": {
"title": "Create Repository",
@@ -55,7 +64,11 @@
"createButton": "Create Branch"
},
"table": {
"branches": "Branches"
"branches": {
"active": "Active Branches",
"stale": "Stale Branches"
},
"lastCommit": "Last commit"
},
"create": {
"title": "Create Branch",

View File

@@ -44,6 +44,9 @@
"setApiKeyNavLink": "API Keys"
}
},
"overview": {
"searchUser": "Search user"
},
"createUser": {
"title": "Create User",
"subtitle": "Create a new user"
@@ -76,15 +79,22 @@
}
},
"publicKey": {
"subtitle": "Public Keys",
"description": "To check signatures (for example for commits), gpg public keys can be stored here. Additionally the keys created by SCM-Manager can be accessed here, too.",
"noStoredKeys": "No keys found.",
"displayName": "Display Name",
"raw": "Key",
"created": "Created on",
"addKey": "Add key",
"raw": "Key",
"download": "Download",
"delete": "Delete",
"download": "Download"
"addSubtitle": "Add new key",
"addKey": "Add key"
},
"apiKey": {
"subtitle": "API Keys",
"text1": "Create and manage personal access tokens to access the REST API or use as a password for SCM clients. The privileges of these tokens are limited to repositories and the selected role.",
"manageRoles": "You may view and create roles in the administration view “Permission Roles”.",
"text2": "To use the token in a REST request, pass it as a cookie named “X-Bearer-Token”. You may use the token as your password for SCM clients, too.",
"noStoredKeys": "No keys found.",
"displayName": "Display Name",
"permissionRole": {
@@ -92,12 +102,10 @@
"help": "The api key will be restricted to permissions of this role"
},
"created": "Created on",
"addSubtitle": "Add new key",
"addKey": "Add key",
"delete": "Delete",
"download": "Download",
"text1": "Create and manage personal access tokens to access the REST API or use as a password for SCM clients. The privileges of these tokens are limited to repositories and the selected role.",
"manageRoles": "You may view and create roles in the administration view “Permission Roles”.",
"text2": "To use the token in a REST request, pass it as a cookie named “X-Bearer-Token”. You may use the token as your password for SCM clients, too.",
"modal": {
"title": "Key created",
"text1": "Your new API key is ready. You can use it as a bearer token for REST calls or as a password for SCM clients.",

View File

@@ -92,7 +92,12 @@ class Groups extends React.Component<Props> {
{this.renderGroupTable()}
{this.renderCreateButton()}
<PageActions>
<OverviewPageActions showCreateButton={canAddGroups} link="groups" label={t("create-group-button.label")} />
<OverviewPageActions
showCreateButton={canAddGroups}
link="groups"
label={t("create-group-button.label")}
searchPlaceholder={t("overview.searchGroup")}
/>
</PageActions>
</Page>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,9 +47,14 @@ const developBranch = {
revision: "revision5",
defaultBranch: false
};
const mainBranch = {
name: "main",
revision: "revision6",
defaultBranch: false
};
const masterBranch = {
name: "master",
revision: "revision6",
revision: "revision7",
defaultBranch: false
};
@@ -66,10 +71,10 @@ describe("order branches", () => {
expect(branches).toEqual([branch3, branch1, branch2]);
});
it("should order special branches as follows: master > default > develop", () => {
const branches = [defaultBranch, developBranch, masterBranch];
it("should order special branches as follows: main > master > default > develop", () => {
const branches = [defaultBranch, mainBranch, developBranch, masterBranch];
orderBranches(branches);
expect(branches).toEqual([masterBranch, defaultBranch, developBranch]);
expect(branches).toEqual([mainBranch, masterBranch, defaultBranch, developBranch]);
});
it("should order special branches but starting with defaultBranch", () => {

View File

@@ -32,10 +32,14 @@ export function orderBranches(branches: Branch[]) {
return -20;
} else if (!a.defaultBranch && b.defaultBranch) {
return 20;
} else if (a.name === "master" && b.name !== "master") {
} else if (a.name === "main" && b.name !== "main") {
return -10;
} else if (a.name !== "master" && b.name === "master") {
} else if (a.name !== "main" && b.name === "main") {
return 10;
} else if (a.name === "master" && b.name !== "master") {
return -9;
} else if (a.name !== "master" && b.name === "master") {
return 9;
} else if (a.name === "default" && b.name !== "default") {
return -10;
} else if (a.name !== "default" && b.name === "default") {

View File

@@ -23,21 +23,25 @@
*/
import React from "react";
import { Link } from "react-router-dom";
import { CardColumnGroup, RepositoryEntry } from "@scm-manager/ui-components";
import { CardColumnGroup, Icon, RepositoryEntry } from "@scm-manager/ui-components";
import { RepositoryGroup } from "@scm-manager/ui-types";
import { Icon } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components";
type Props = WithTranslation & {
group: RepositoryGroup;
};
const SizedIcon = styled(Icon)`
font-size: 1.33rem;
`;
class RepositoryGroupEntry extends React.Component<Props> {
render() {
const { group, t } = this.props;
const settingsLink = group.namespace?._links?.permissions && (
<Link to={`/namespace/${group.name}/settings`}>
<Icon color={"is-link"} name={"cog"} title={t("repositoryOverview.settings.tooltip")} />
<SizedIcon color={"is-link"} name={"cog"} title={t("repositoryOverview.settings.tooltip")} />
</Link>
);
const namespaceHeader = (

View File

@@ -101,8 +101,12 @@ class Overview extends React.Component<Props> {
}
};
getNamespaceFilterPlaceholder = () => {
return this.props.t("overview.allNamespaces");
};
namespaceSelected = (newNamespace: string) => {
if (newNamespace === "") {
if (newNamespace === this.getNamespaceFilterPlaceholder()) {
this.props.history.push("/repos/");
} else {
this.props.history.push(`/repos/${newNamespace}/`);
@@ -111,8 +115,10 @@ class Overview extends React.Component<Props> {
render() {
const { error, loading, showCreateButton, namespace, namespaces, t } = this.props;
const namespacesToRender = namespaces ? ["", ...namespaces._embedded.namespaces.map(n => n.namespace).sort()] : [];
const namespaceFilterPlaceholder = this.getNamespaceFilterPlaceholder();
const namespacesToRender = namespaces
? [namespaceFilterPlaceholder, ...namespaces._embedded.namespaces.map(n => n.namespace).sort()]
: [];
return (
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
@@ -126,6 +132,7 @@ class Overview extends React.Component<Props> {
link="repos"
label={t("overview.createButton")}
testId="repository-overview"
searchPlaceholder={t("overview.searchRepository")}
/>
</PageActions>
</Page>

View File

@@ -23,7 +23,7 @@
*/
import React, { FC, useEffect, useState } from "react";
import { apiClient, ErrorNotification, InputField, Level, Loading, SubmitButton } from "@scm-manager/ui-components";
import { apiClient, ErrorNotification, InputField, Level, Loading, SubmitButton, Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { CONTENT_TYPE_API_KEY } from "./SetApiKeys";
import { connect } from "react-redux";
@@ -105,6 +105,8 @@ const AddApiKey: FC<Props> = ({
return (
<>
<hr />
<Subtitle subtitle={t("apiKey.addSubtitle")} />
{newKeyModal}
<InputField label={t("apiKey.displayName")} value={displayName} onChange={setDisplayName} />
<RoleSelector

View File

@@ -39,8 +39,8 @@ export const ApiKeyEntry: FC<Props> = ({ apiKey, onDelete }) => {
if (apiKey?._links?.delete) {
deleteButton = (
<a className="level-item" onClick={() => onDelete((apiKey._links.delete as Link).href)}>
<span className="icon is-small">
<Icon name="trash" className="fas" title={t("apiKey.delete")} />
<span className="icon">
<Icon name="trash" title={t("apiKey.delete")} color="inherit" />
</span>
</a>
);
@@ -52,7 +52,7 @@ export const ApiKeyEntry: FC<Props> = ({ apiKey, onDelete }) => {
<td>{apiKey.displayName}</td>
<td>{apiKey.permissionRole}</td>
<td className="is-hidden-mobile">
<DateFromNow date={apiKey.created}/>
<DateFromNow date={apiKey.created} />
</td>
<td className="is-darker">{deleteButton}</td>
</tr>

View File

@@ -25,11 +25,10 @@
import { Collection, Links, User, Me } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
import { apiClient, ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
import ApiKeyTable from "./ApiKeyTable";
import AddApiKey from "./AddApiKey";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
export type ApiKeysCollection = Collection & {
_embedded: {
@@ -51,10 +50,6 @@ type Props = {
user: User | Me;
};
const Subtitle = styled.div`
margin-bottom: 1rem;
`;
const SetApiKeys: FC<Props> = ({ user }) => {
const [t] = useTranslation("users");
const [error, setError] = useState<undefined | Error>();
@@ -94,14 +89,13 @@ const SetApiKeys: FC<Props> = ({ user }) => {
return (
<>
<div className={"media-content"}>
<p>{t("apiKey.text1")} <Link to={"/admin/roles/"}>{t("apiKey.manageRoles")}</Link></p>
<p>{t("apiKey.text2")}</p>
</div>
<hr />
<Subtitle subtitle={t("apiKey.subtitle")} />
<p>
{t("apiKey.text1")} <Link to={"/admin/roles/"}>{t("apiKey.manageRoles")}</Link>
</p>
<p>{t("apiKey.text2")}</p>
<br />
<ApiKeyTable apiKeys={apiKeys} onDelete={onDelete} />
<hr />
<Subtitle className={"media-content"}><h2 className={"title is-4"}>Create new key</h2></Subtitle>
{createLink && <AddApiKey createLink={createLink} refresh={fetchApiKeys} />}
</>
);

View File

@@ -23,7 +23,6 @@
*/
import React, { FC, useState } from "react";
import { User, Link, Links, Collection } from "@scm-manager/ui-types/src";
import {
ErrorNotification,
InputField,
@@ -31,7 +30,8 @@ import {
Textarea,
SubmitButton,
apiClient,
Loading
Loading,
Subtitle
} from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { CONTENT_TYPE_PUBLIC_KEY } from "./SetPublicKeys";
@@ -77,6 +77,8 @@ const AddPublicKey: FC<Props> = ({ createLink, refresh }) => {
return (
<>
<hr />
<Subtitle subtitle={t("publicKey.addSubtitle")} />
<InputField label={t("publicKey.displayName")} value={displayName} onChange={setDisplayName} />
<Textarea name="raw" label={t("publicKey.raw")} value={raw} onChange={setRaw} />
<Level

View File

@@ -22,24 +22,28 @@
* SOFTWARE.
*/
import React, {FC} from "react";
import {DateFromNow, DeleteButton} from "@scm-manager/ui-components";
import {PublicKey} from "./SetPublicKeys";
import {useTranslation} from "react-i18next";
import {Link} from "@scm-manager/ui-types";
import React, { FC } from "react";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
import { PublicKey } from "./SetPublicKeys";
import { useTranslation } from "react-i18next";
import { Link } from "@scm-manager/ui-types";
type Props = {
publicKey: PublicKey;
onDelete: (link: string) => void;
};
export const PublicKeyEntry: FC<Props> = ({publicKey, onDelete}) => {
export const PublicKeyEntry: FC<Props> = ({ publicKey, onDelete }) => {
const [t] = useTranslation("users");
let deleteButton;
if (publicKey?._links?.delete) {
deleteButton = (
<DeleteButton label={t("publicKey.delete")} action={() => onDelete((publicKey._links.delete as Link).href)}/>
<a className="level-item" onClick={() => onDelete((publicKey._links.delete as Link).href)}>
<span className="icon">
<Icon name="trash" title={t("publicKey.delete")} color="inherit" />
</span>
</a>
);
}
@@ -48,11 +52,18 @@ export const PublicKeyEntry: FC<Props> = ({publicKey, onDelete}) => {
<tr>
<td>{publicKey.displayName}</td>
<td className="is-hidden-mobile">
<DateFromNow date={publicKey.created}/>
<DateFromNow date={publicKey.created} />
</td>
<td className="is-hidden-mobile">{publicKey._links?.raw ?
<a href={(publicKey._links.raw as Link).href}>{publicKey.id}</a> : publicKey.id}</td>
<td>{deleteButton}</td>
<td className="is-hidden-mobile">
{publicKey._links?.raw ? (
<a title={t("publicKey.download")} href={(publicKey._links.raw as Link).href}>
{publicKey.id}
</a>
) : (
publicKey.id
)}
</td>
<td className="is-darker">{deleteButton}</td>
</tr>
</>
);

View File

@@ -24,9 +24,10 @@
import { Collection, Link, Links, User, Me } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import AddPublicKey from "./AddPublicKey";
import PublicKeyTable from "./PublicKeyTable";
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
import { apiClient, ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
export type PublicKeysCollection = Collection & {
_embedded: {
@@ -49,6 +50,7 @@ type Props = {
};
const SetPublicKeys: FC<Props> = ({ user }) => {
const [t] = useTranslation("users");
const [error, setError] = useState<undefined | Error>();
const [loading, setLoading] = useState(false);
const [publicKeys, setPublicKeys] = useState<PublicKeysCollection | undefined>(undefined);
@@ -86,6 +88,9 @@ const SetPublicKeys: FC<Props> = ({ user }) => {
return (
<>
<Subtitle subtitle={t("publicKey.subtitle")} />
<p>{t("publicKey.description")}</p>
<br />
<PublicKeyTable publicKeys={publicKeys} onDelete={onDelete} />
{createLink && <AddPublicKey createLink={createLink} refresh={fetchPublicKeys} />}
</>

View File

@@ -93,7 +93,12 @@ class Users extends React.Component<Props> {
{this.renderUserTable()}
{this.renderCreateButton()}
<PageActions>
<OverviewPageActions showCreateButton={canAddUsers} link="users" label={t("users.createButton")} />
<OverviewPageActions
showCreateButton={canAddUsers}
link="users"
label={t("users.createButton")}
searchPlaceholder={t("overview.searchUser")}
/>
</PageActions>
</Page>
);

View File

@@ -0,0 +1,56 @@
/*
* 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.
*/
const fs = require("fs");
const dirs = fs.readdirSync("public/locales");
describe("locales folder", () => {
it("should contain folder en", () => {
expect(dirs).toContain("en");
});
});
dirs.forEach(languageDirName => {
const languageDir = `public/locales/${languageDirName}`;
const languageDirFiles = fs.readdirSync(languageDir);
languageDirFiles
.filter(fileName => fileName.endsWith(".json"))
.forEach(translationFileName => {
const translationFile = `${languageDir}/${translationFileName}`;
describe(`translation file ${translationFile}`, () => {
it("should contain only valid json", () => {
const jsonContent = fs.readFileSync(translationFile);
let json;
try {
json = JSON.parse(jsonContent);
} catch (ex) {
// eslint-disable-next-line no-undef
fail(new Error(`invalid json: ${ex}`));
}
expect(json).not.toBe({});
});
});
});
});

View File

@@ -295,7 +295,7 @@
<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
<version>8.1.1</version>
<version>9.1.3</version>
</dependency>
<!-- template engine -->
@@ -691,7 +691,7 @@
<jjwt.version>0.11.2</jjwt.version>
<selenium.version>2.53.1</selenium.version>
<wagon.version>1.0</wagon.version>
<mustache.version>0.9.6-scm1</mustache.version>
<mustache.version>0.9.7</mustache.version>
<netbeans.hint.deploy.server>Tomcat</netbeans.hint.deploy.server>
<sonar.issue.ignore.multicriteria>e1</sonar.issue.ignore.multicriteria>
<sonar.issue.ignore.multicriteria.e1.ruleKey>javascript:S3827</sonar.issue.ignore.multicriteria.e1.ruleKey>

View File

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

View File

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

View File

@@ -192,10 +192,16 @@ public class DefaultPluginManager implements PluginManager {
dependencyTracker.addInstalled(plugin.getDescriptor());
pendingInstallations.add(pending);
eventBus.post(new PluginEvent(PluginEvent.PluginEventType.INSTALLED, plugin));
} catch (PluginInstallException ex) {
cancelPending(pendingInstallations);
eventBus.post(new PluginEvent(PluginEvent.PluginEventType.INSTALLATION_FAILED, plugin));
throw ex;
} catch (PluginInstallException installException) {
try {
cancelPending(pendingInstallations);
} catch (PluginFailedToCancelInstallationException cancelInstallationException) {
LOG.error("could not install plugin {}; uninstallation failed (see next exception)", plugin.getDescriptor().getInformation().getName(), installException);
throw cancelInstallationException;
} finally {
eventBus.post(new PluginEvent(PluginEvent.PluginEventType.INSTALLATION_FAILED, plugin));
}
throw installException;
}
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.plugin;
import org.slf4j.Logger;
@@ -50,10 +50,14 @@ class PendingPluginInstallation {
void cancel() {
String name = plugin.getDescriptor().getInformation().getName();
LOG.info("cancel installation of plugin {}", name);
try {
Files.delete(file);
} catch (IOException ex) {
throw new PluginFailedToCancelInstallationException("failed to cancel plugin installation ", name, ex);
if (Files.exists(file)) {
try {
Files.delete(file);
} catch (IOException ex) {
throw new PluginFailedToCancelInstallationException("failed to cancel plugin installation ", name, ex);
}
} else {
LOG.info("plugin file {} did not exists for plugin {}; nothing deleted", file, name);
}
}
}

View File

@@ -108,8 +108,8 @@ public final class PluginTree
throw new PluginConditionFailedException(
condition,
String.format(
"could not load plugin %s, the plugin condition does not match",
plugin.getInformation().getId()
"could not load plugin %s, the plugin condition does not match: %s",
plugin.getInformation().getId(), condition
)
);
//J+

View File

@@ -59,8 +59,13 @@ class PendingPluginInstallationTest {
}
@Test
void shouldThrowExceptionIfCancelFailed(@TempDir Path directory) {
void shouldThrowExceptionIfCancelFailed(@TempDir Path directory) throws IOException {
Path file = directory.resolve("file");
Files.createDirectory(file);
Path makeFileNotDeletable = file.resolve("not_deletable");
Files.write(makeFileNotDeletable, "42".getBytes());
when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin");
PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file);

View File

@@ -48,12 +48,15 @@ import java.io.IOException;
import java.io.PrintWriter;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -239,6 +242,30 @@ class I18nServletTest {
assertJson(json);
}
@Test
void shouldNotHaveInvalidPluginsJsonFiles() throws IOException {
String path = getClass().getClassLoader().getResource("locales/en/plugins.json").getPath();
assertThat(path).isNotNull();
Path filePath = Paths.get(path);
Path translationRootPath = filePath.getParent().getParent();
assertThat(translationRootPath).isDirectoryContaining("glob:**/en");
Files
.list(translationRootPath)
.filter(Files::isDirectory)
.map(localePath -> localePath.resolve("plugins.json"))
.forEach(this::validatePluginsJson);
}
private void validatePluginsJson(Path path) {
try {
new ObjectMapper().readTree(path.toFile());
} catch (IOException e) {
fail("error while parsing translation file " + path, e);
}
}
private void verifyHeaders(HttpServletResponse response) {
verify(response).setCharacterEncoding("UTF-8");
verify(response).setContentType("application/json");