This commit is contained in:
Mohamed Karray
2018-10-09 17:13:23 +02:00
154 changed files with 4672 additions and 12548 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
# ignore everything except scm-server.tar.gz
**
!scm-server/target/*.tar.gz

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM openjdk:8u171-alpine3.8
ENV SCM_HOME=/var/lib/scm
RUN set -x \
&& apk add --no-cache mercurial bash \
&& addgroup -S -g 1000 scm \
&& adduser -S -s /bin/false -G scm -h /opt/scm-server -D -H -u 1000 scm \
&& mkdir ${SCM_HOME} \
&& chown scm:scm ${SCM_HOME}
ADD scm-server/target/scm-server-app.tar.gz /opt
RUN chown -R scm:scm /opt/scm-server
WORKDIR /opt/scm-server
VOLUME [ "${SCM_HOME}", "/opt/scm-server/var/log" ]
EXPOSE 8080
USER scm
ENTRYPOINT [ "/opt/scm-server/bin/scm-server" ]

39
Jenkinsfile vendored
View File

@@ -4,14 +4,15 @@
@Library('github.com/cloudogu/ces-build-lib@59d3e94')
import com.cloudogu.ces.cesbuildlib.*
node() { // No specific label
node('docker') {
// Change this as when we go back to default - necessary for proper SonarQube analysis
mainBranch = "2.0.0-m3"
properties([
// Keep only the last 10 build to preserve space
buildDiscarder(logRotator(numToKeepStr: '10'))
buildDiscarder(logRotator(numToKeepStr: '10')),
disableConcurrentBuilds()
])
timeout(activity: true, time: 20, unit: 'MINUTES') {
@@ -44,6 +45,26 @@ node() { // No specific label
currentBuild.result = 'UNSTABLE'
}
}
def commitHash = getCommitHash()
def dockerImageTag = "2.0.0-dev-${commitHash.substring(0,7)}-${BUILD_NUMBER}"
if (isMainBranch()) {
stage('Docker') {
def image = docker.build('cloudogu/scm-manager')
docker.withRegistry('', 'hub.docker.com-cesmarvin') {
image.push(dockerImageTag)
image.push('latest')
}
}
stage('Deployment') {
build job: 'scm-manager/next-scm.cloudogu.com', propagate: false, wait: false, parameters: [
string(name: 'changeset', value: commitHash),
string(name: 'imageTag', value: dockerImageTag)
]
}
}
}
// Archive Unit and integration test results, if any
@@ -62,7 +83,7 @@ Maven setupMavenBuild() {
// Keep this version number in sync with .mvn/maven-wrapper.properties
Maven mvn = new MavenInDocker(this, "3.5.2-jdk-8")
if (mainBranch.equals(env.BRANCH_NAME)) {
if (isMainBranch()) {
// Release starts javadoc, which takes very long, so do only for certain branches
mvn.additionalArgs += ' -DperformRelease'
// JDK8 is more strict, we should fix this before the next release. Right now, this is just not the focus, yet.
@@ -89,7 +110,7 @@ void analyzeWith(Maven mvn) {
"-Dsonar.pullrequest.bitbucketcloud.repository=scm-manager "
} else {
mvnArgs += " -Dsonar.branch.name=${env.BRANCH_NAME} "
if (!mainBranch.equals(env.BRANCH_NAME)) {
if (!isMainBranch()) {
// Avoid exception "The main branch must not have a target" on main branch
mvnArgs += " -Dsonar.branch.target=${mainBranch} "
}
@@ -98,9 +119,13 @@ void analyzeWith(Maven mvn) {
}
}
boolean isMainBranch() {
return mainBranch.equals(env.BRANCH_NAME)
}
boolean waitForQualityGateWebhookToBeCalled() {
boolean isQualityGateSucceeded = true
timeout(time: 2, unit: 'MINUTES') { // Needed when there is no webhook for example
timeout(time: 5, unit: 'MINUTES') { // Needed when there is no webhook for example
def qGate = waitForQualityGate()
echo "SonarQube Quality Gate status: ${qGate.status}"
if (qGate.status != 'OK') {
@@ -114,6 +139,10 @@ String getCommitAuthorComplete() {
new Sh(this).returnStdOut 'hg log --branch . --limit 1 --template "{author}"'
}
String getCommitHash() {
new Sh(this).returnStdOut 'hg log --branch . --limit 1 --template "{node}"'
}
String getCommitAuthorEmail() {
def matcher = getCommitAuthorComplete() =~ "<(.*?)>"
matcher ? matcher[0][1] : ""

View File

@@ -0,0 +1,21 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*~
# Various IDEs
.project
.idea/
*.tmproj

View File

@@ -0,0 +1,5 @@
apiVersion: v1
appVersion: "1.0"
description: A Helm chart for SCM-Manager
name: scm-manager
version: 0.1.0

View File

@@ -0,0 +1,19 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "scm-manager.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get svc -w {{ include "scm-manager.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "scm-manager.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ include "scm-manager.name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl port-forward $POD_NAME 8080:80
{{- end }}

View File

@@ -0,0 +1,32 @@
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "scm-manager.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "scm-manager.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "scm-manager.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}

View File

@@ -0,0 +1,160 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "scm-manager.fullname" . }}
labels:
app: {{ include "scm-manager.name" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
data:
server-config.xml: |
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_0.dtd">
<Configure id="ScmServer" class="org.eclipse.jetty.server.Server">
<New id="httpConfig" class="org.eclipse.jetty.server.HttpConfiguration">
<!-- increase header size for mercurial -->
<Set name="requestHeaderSize">16384</Set>
<Set name="responseHeaderSize">16384</Set>
{{- if .Values.ingress.enabled -}}
<!--
We have to enable ForwardedRequestCustomizer in order to understand X-Forwarded-xxx headers.
Without the ForwardedRequestCustomizer, scm will possibly generate wrong links
-->
<Call name="addCustomizer">
<Arg><New class="org.eclipse.jetty.server.ForwardedRequestCustomizer"/></Arg>
</Call>
{{- end }}
</New>
<!--
Connectors
-->
<Call name="addConnector">
<Arg>
<New class="org.eclipse.jetty.server.ServerConnector">
<Arg name="server">
<Ref refid="ScmServer" />
</Arg>
<Arg name="factories">
<Array type="org.eclipse.jetty.server.ConnectionFactory">
<Item>
<New class="org.eclipse.jetty.server.HttpConnectionFactory">
<Arg name="config">
<Ref refid="httpConfig" />
</Arg>
</New>
</Item>
</Array>
</Arg>
<Set name="port">
<SystemProperty name="jetty.port" default="8080" />
</Set>
</New>
</Arg>
</Call>
<New id="scm-webapp" class="org.eclipse.jetty.webapp.WebAppContext">
<Set name="contextPath">/scm</Set>
<Set name="war">
<SystemProperty name="basedir" default="."/>/var/webapp/scm-webapp.war</Set>
<!-- disable directory listings -->
<Call name="setInitParameter">
<Arg>org.eclipse.jetty.servlet.Default.dirAllowed</Arg>
<Arg>false</Arg>
</Call>
<Set name="tempDirectory">
<SystemProperty name="basedir" default="."/>/work/scm
</Set>
</New>
<New id="docroot" class="org.eclipse.jetty.webapp.WebAppContext">
<Set name="contextPath">/</Set>
<Set name="baseResource">
<New class="org.eclipse.jetty.util.resource.ResourceCollection">
<Arg>
<Array type="java.lang.String">
<Item>
<SystemProperty name="basedir" default="."/>/var/webapp/docroot</Item>
</Array>
</Arg>
</New>
</Set>
<Set name="tempDirectory">
<SystemProperty name="basedir" default="."/>/work/docroot
</Set>
</New>
<Set name="handler">
<New class="org.eclipse.jetty.server.handler.HandlerCollection">
<Set name="handlers">
<Array type="org.eclipse.jetty.server.Handler">
<Item>
<Ref id="scm-webapp" />
</Item>
<Item>
<Ref id="docroot" />
</Item>
</Array>
</Set>
</New>
</Set>
</Configure>
logging.xml: |
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<--
in a container environment we only need stdout
-->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<logger name="sonia.scm" level="INFO" />
<!-- suppress massive gzip logging -->
<logger name="sonia.scm.filter.GZipFilter" level="WARN" />
<logger name="sonia.scm.filter.GZipResponseStream" level="WARN" />
<logger name="sonia.scm.util.ServiceUtil" level="WARN" />
<!-- event bus -->
<logger name="sonia.scm.event.LegmanScmEventBus" level="INFO" />
<!-- shiro -->
<!--
<logger name="org.apache.shiro" level="INFO" />
<logger name="org.apache.shiro.authc.pam.ModularRealmAuthenticator" level="DEBUG" />
-->
<!-- svnkit -->
<!--
<logger name="svnkit" level="WARN" />
<logger name="svnkit.network" level="DEBUG" />
<logger name="svnkit.fsfs" level="WARN" />
-->
<!-- javahg -->
<!--
<logger name="com.aragost.javahg" level="DEBUG" />
-->
<!-- ehcache -->
<!--
<logger name="net.sf.ehcache" level="DEBUG" />
-->
<root level="WARN">
<appender-ref ref="STDOUT" />
</root>
</configuration>

View File

@@ -0,0 +1,77 @@
apiVersion: apps/v1beta2
kind: Deployment
metadata:
name: {{ include "scm-manager.fullname" . }}
labels:
app: {{ include "scm-manager.name" . }}
chart: {{ include "scm-manager.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
replicas: 1 # could not be scaled
strategy:
type: Recreate
selector:
matchLabels:
app: {{ include "scm-manager.name" . }}
release: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ include "scm-manager.name" . }}
release: {{ .Release.Name }}
spec:
initContainers:
- name: volume-permissions
image: alpine:3.8
imagePullPolicy: IfNotPresent
command: ['sh', '-c', 'chown 1000:1000 /data']
volumeMounts:
- name: data
mountPath: /data
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /scm
port: http
readinessProbe:
httpGet:
path: /scm
port: http
resources:
{{ toYaml .Values.resources | indent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/scm
- name: config
mountPath: /opt/scm-server/conf
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "scm-manager.fullname" . }}
{{- else }}
emptyDir: {}
{{- end }}
- name: config
configMap:
name: {{ include "scm-manager.fullname" . }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{ toYaml . | indent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{ toYaml . | indent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{ toYaml . | indent 8 }}
{{- end }}

View File

@@ -0,0 +1,38 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "scm-manager.fullname" . -}}
{{- $ingressPath := .Values.ingress.path -}}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
app: {{ include "scm-manager.name" . }}
chart: {{ include "scm-manager.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- with .Values.ingress.annotations }}
annotations:
{{ toYaml . | indent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $ingressPath }}
backend:
serviceName: {{ $fullName }}
servicePort: http
{{- end }}
{{- end }}

View File

@@ -0,0 +1,24 @@
{{- if .Values.persistence.enabled -}}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ include "scm-manager.fullname" . }}
labels:
app: {{ include "scm-manager.name" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
spec:
accessModes:
- {{ .Values.persistence.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- if .Values.persistence.storageClass }}
{{- if (eq "-" .Values.persistence.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "scm-manager.fullname" . }}
labels:
app: {{ include "scm-manager.name" . }}
chart: {{ include "scm-manager.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
protocol: TCP
name: http
selector:
app: {{ include "scm-manager.name" . }}
release: {{ .Release.Name }}

View File

@@ -0,0 +1,65 @@
# Default values for scm-manager.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# replicaCount: 1
image:
repository: cloudogu/scm-manager
# TODO change after release, to something more stable
tag: latest
pullPolicy: Always
nameOverride: ""
fullnameOverride: ""
service:
type: LoadBalancer
port: 80
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
path: /
hosts:
- scm-manager.local
tls: []
# - secretName: scm-manager-tls
# hosts:
# - scm-manager.local
## Enable persistence using Persistent Volume Claims
## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/
##
persistence:
enabled: true
## ghost data Persistent Volume Storage Class
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
accessMode: ReadWriteOnce
size: 12Gi
resources:
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
limits:
cpu: 2000m
memory: 2048Mi
requests:
cpu: 50m
memory: 256Mi
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -188,15 +188,14 @@
<scope>test</scope>
</dependency>
<!-- TODO replace by proper version from maven central (group: com.github.sdorra) once its there. -->
<dependency>
<groupId>com.github.sdorra.shiro-static-permissions</groupId>
<groupId>com.github.sdorra</groupId>
<artifactId>ssp-lib</artifactId>
<version>${ssp.version}</version>
</dependency>
<dependency>
<groupId>com.github.sdorra.shiro-static-permissions</groupId>
<groupId>com.github.sdorra</groupId>
<artifactId>ssp-processor</artifactId>
<version>${ssp.version}</version>
<optional>true</optional>
@@ -765,7 +764,7 @@
<jetty.maven.version>9.2.10.v20150310</jetty.maven.version>
<!-- security libraries -->
<ssp.version>967c8fd521</ssp.version>
<ssp.version>1.1.0</ssp.version>
<shiro.version>1.4.0</shiro.version>
<!-- repostitory libraries -->

View File

@@ -94,6 +94,12 @@
<artifactId>javax.ws.rs-api</artifactId>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxrs</artifactId>
<scope>test</scope>
</dependency>
<!-- json -->
<dependency>
@@ -160,14 +166,13 @@
<scope>provided</scope>
</dependency>
<!-- TODO replace by proper version from maven central (group: com.github.sdorra) once its there. -->
<dependency>
<groupId>com.github.sdorra.shiro-static-permissions</groupId>
<groupId>com.github.sdorra</groupId>
<artifactId>ssp-lib</artifactId>
</dependency>
<dependency>
<groupId>com.github.sdorra.shiro-static-permissions</groupId>
<groupId>com.github.sdorra</groupId>
<artifactId>ssp-processor</artifactId>
<optional>true</optional>
</dependency>

View File

@@ -4,11 +4,11 @@ import java.net.URI;
public interface ScmPathInfo {
String REST_API_PATH = "/api/rest";
String REST_API_PATH = "/api";
URI getApiRestUri();
default URI getRootUri() {
return getApiRestUri().resolve("../..");
return getApiRestUri().resolve("..");
}
}

View File

@@ -22,7 +22,7 @@ import com.github.sdorra.ssp.StaticPermissions;
@StaticPermissions(
value = "configuration",
permissions = {"read", "write"},
globalPermissions = {}
globalPermissions = {"list"}
)
public interface Configuration extends PermissionObject {
}

View File

@@ -1,125 +0,0 @@
/**
* Copyright (c) 2010, Sebastian Sdorra
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* 3. Neither the name of SCM-Manager; nor the names of its
* contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* http://bitbucket.org/sdorra/scm-manager
*
*/
package sonia.scm.filter;
//~--- non-JDK imports --------------------------------------------------------
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.Priority;
import sonia.scm.util.WebUtil;
import sonia.scm.web.filter.HttpFilter;
//~--- JDK imports ------------------------------------------------------------
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Filter for gzip encoding.
*
* @author Sebastian Sdorra
* @since 1.15
*/
@Priority(Filters.PRIORITY_PRE_BASEURL)
@WebElement(value = Filters.PATTERN_RESOURCE_REGEX, regex = true)
public class GZipFilter extends HttpFilter
{
/**
* the logger for GZipFilter
*/
private static final Logger logger =
LoggerFactory.getLogger(GZipFilter.class);
//~--- get methods ----------------------------------------------------------
/**
* Return the configuration for the gzip filter.
*
*
* @return gzip filter configuration
*/
public GZipFilterConfig getConfig()
{
return config;
}
//~--- methods --------------------------------------------------------------
/**
* Encodes the response, if the request has support for gzip encoding.
*
*
* @param request http request
* @param response http response
* @param chain filter chain
*
* @throws IOException
* @throws ServletException
*/
@Override
protected void doFilter(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws IOException, ServletException
{
if (WebUtil.isGzipSupported(request))
{
if (logger.isTraceEnabled())
{
logger.trace("compress output with gzip");
}
GZipResponseWrapper wrappedResponse = new GZipResponseWrapper(response,
config);
chain.doFilter(request, wrappedResponse);
wrappedResponse.finishResponse();
}
else
{
chain.doFilter(request, response);
}
}
//~--- fields ---------------------------------------------------------------
/** gzip filter configuration */
private GZipFilterConfig config = new GZipFilterConfig();
}

View File

@@ -0,0 +1,24 @@
package sonia.scm.filter;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.util.WebUtil;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.ext.Provider;
import java.io.IOException;
import java.util.zip.GZIPOutputStream;
@Provider
@Slf4j
public class GZipResponseFilter implements ContainerResponseFilter {
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
if (WebUtil.isGzipSupported(requestContext::getHeaderString)) {
log.trace("compress output with gzip");
GZIPOutputStream wrappedResponse = new GZIPOutputStream(responseContext.getEntityStream());
responseContext.getHeaders().add("Content-Encoding", "gzip");
responseContext.setEntityStream(wrappedResponse);
}
}
}

View File

@@ -60,7 +60,7 @@ import java.util.List;
*
* @author Sebastian Sdorra
*/
@StaticPermissions("group")
@StaticPermissions(value = "group", globalPermissions = {"create", "list"})
@XmlRootElement(name = "groups")
@XmlAccessorType(XmlAccessType.FIELD)
public class Group extends BasicPropertiesAware

View File

@@ -82,6 +82,22 @@ public class ChangesetPagingResult implements Iterable<Changeset>, Serializable
{
this.total = total;
this.changesets = changesets;
this.branchName = null;
}
/**
* Constructs a new changeset paging result for a specific branch.
*
*
* @param total total number of changesets
* @param changesets current list of fetched changesets
* @param branchName branch name this result was created for
*/
public ChangesetPagingResult(int total, List<Changeset> changesets, String branchName)
{
this.total = total;
this.changesets = changesets;
this.branchName = branchName;
}
//~--- methods --------------------------------------------------------------
@@ -158,6 +174,7 @@ public class ChangesetPagingResult implements Iterable<Changeset>, Serializable
return MoreObjects.toStringHelper(this)
.add("changesets", changesets)
.add("total", total)
.add("branch", branchName)
.toString();
//J+
}
@@ -186,37 +203,35 @@ public class ChangesetPagingResult implements Iterable<Changeset>, Serializable
return total;
}
//~--- set methods ----------------------------------------------------------
/**
* Sets the current list of changesets.
*
*
* @param changesets current list of changesets
*/
public void setChangesets(List<Changeset> changesets)
void setChangesets(List<Changeset> changesets)
{
this.changesets = changesets;
}
/**
* Sets the total number of changesets
*
*
* @param total total number of changesets
*/
public void setTotal(int total)
void setTotal(int total)
{
this.total = total;
}
void setBranchName(String branchName) {
this.branchName = branchName;
}
/**
* Returns the branch name this result was created for. This can either be an explicit branch ("give me all
* changesets for branch xyz") or an implicit one ("give me the changesets for the default").
*/
public String getBranchName() {
return branchName;
}
//~--- fields ---------------------------------------------------------------
/** current list of changesets */
@XmlElement(name = "changeset")
@XmlElementWrapper(name = "changesets")
private List<Changeset> changesets;
/** total number of changesets */
private int total;
private String branchName;
}

View File

@@ -55,11 +55,10 @@ import java.security.Principal;
*
* @author Sebastian Sdorra
*/
@StaticPermissions("user")
@StaticPermissions(value = "user", globalPermissions = {"create", "list"})
@XmlRootElement(name = "users")
@XmlAccessorType(XmlAccessType.FIELD)
public class
User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject
public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject
{
/** Field description */

View File

@@ -49,6 +49,7 @@ import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import java.util.function.Function;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -266,7 +267,12 @@ public final class WebUtil
*/
public static boolean isGzipSupported(HttpServletRequest request)
{
String enc = request.getHeader(HEADER_ACCEPTENCODING);
return isGzipSupported(request::getHeader);
}
public static boolean isGzipSupported(Function<String, String> headerResolver)
{
String enc = headerResolver.apply(HEADER_ACCEPTENCODING);
return (enc != null) && enc.contains("gzip");
}

View File

@@ -0,0 +1,40 @@
package sonia.scm.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map;
public abstract class JsonEnricherBase implements JsonEnricher {
private final ObjectMapper objectMapper;
protected JsonEnricherBase(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
protected boolean resultHasMediaType(String mediaType, JsonEnricherContext context) {
return mediaType.equals(context.getResponseMediaType().toString());
}
protected JsonNode value(Object object) {
return objectMapper.convertValue(object, JsonNode.class);
}
protected ObjectNode createObject() {
return objectMapper.createObjectNode();
}
protected ObjectNode createObject(Map<String, Object> values) {
ObjectNode object = createObject();
values.forEach((key, value) -> object.set(key, value(value)));
return object;
}
protected void addPropertyNode(JsonNode parent, String newKey, JsonNode child) {
((ObjectNode) parent).set(newKey, child);
}
}

View File

@@ -15,6 +15,7 @@ public class VndMediaType {
public static final String PLAIN_TEXT_PREFIX = "text/" + SUBTYPE_PREFIX;
public static final String PLAIN_TEXT_SUFFIX = "+plain;v=" + VERSION;
public static final String INDEX = PREFIX + "index" + SUFFIX;
public static final String USER = PREFIX + "user" + SUFFIX;
public static final String GROUP = PREFIX + "group" + SUFFIX;
public static final String REPOSITORY = PREFIX + "repository" + SUFFIX;

View File

@@ -114,7 +114,7 @@ public class InitializingHttpScmProtocolWrapperTest {
}
private OngoingStubbing<ScmPathInfo> mockSetPathInfo() {
return when(pathInfoStore.get()).thenReturn(() -> URI.create("http://example.com/scm/api/rest/"));
return when(pathInfoStore.get()).thenReturn(() -> URI.create("http://example.com/scm/api/"));
}
}

View File

@@ -0,0 +1,51 @@
package sonia.scm.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import javax.ws.rs.core.MediaType;
import static java.util.Collections.singletonMap;
import static org.assertj.core.api.Assertions.assertThat;
public class JsonEnricherBaseTest {
private ObjectMapper objectMapper = new ObjectMapper();
private TestJsonEnricher enricher = new TestJsonEnricher(objectMapper);
@Test
public void testResultHasMediaType() {
JsonEnricherContext context = new JsonEnricherContext(null, MediaType.APPLICATION_JSON_TYPE, null);
assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_JSON, context)).isTrue();
assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_XML, context)).isFalse();
}
@Test
public void testAppendLink() {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode links = objectMapper.createObjectNode();
root.set("_links", links);
JsonEnricherContext context = new JsonEnricherContext(null, MediaType.APPLICATION_JSON_TYPE, root);
enricher.enrich(context);
assertThat(links.get("awesome").get("href").asText()).isEqualTo("/my/awesome/link");
}
private static class TestJsonEnricher extends JsonEnricherBase {
public TestJsonEnricher(ObjectMapper objectMapper) {
super(objectMapper);
}
@Override
public void enrich(JsonEnricherContext context) {
JsonNode gitConfigRefNode = createObject(singletonMap("href", value("/my/awesome/link")));
addPropertyNode(context.getResponseEntity().get("_links"), "awesome", gitConfigRefNode);
}
}
}

View File

@@ -0,0 +1,48 @@
package sonia.scm.it;
import io.restassured.RestAssured;
import org.apache.http.HttpStatus;
import org.junit.Test;
import sonia.scm.it.utils.RestUtil;
import sonia.scm.web.VndMediaType;
import static sonia.scm.it.utils.RegExMatcher.matchesPattern;
import static sonia.scm.it.utils.RestUtil.given;
public class IndexITCase {
@Test
public void shouldLinkEverythingForAdmin() {
given(VndMediaType.INDEX)
.when()
.get(RestUtil.createResourceUrl(""))
.then()
.statusCode(HttpStatus.SC_OK)
.body(
"_links.repositories.href", matchesPattern(".+/repositories/"),
"_links.users.href", matchesPattern(".+/users/"),
"_links.groups.href", matchesPattern(".+/groups/"),
"_links.config.href", matchesPattern(".+/config"),
"_links.gitConfig.href", matchesPattern(".+/config/git"),
"_links.hgConfig.href", matchesPattern(".+/config/hg"),
"_links.svnConfig.href", matchesPattern(".+/config/svn")
);
}
@Test
public void shouldCreateLoginLinksForAnonymousAccess() {
RestAssured.given() // do not specify user credentials
.when()
.get(RestUtil.createResourceUrl(""))
.then()
.statusCode(HttpStatus.SC_OK)
.body(
"_links.login.href", matchesPattern(".+/auth/.+")
);
}
}

View File

@@ -62,18 +62,4 @@ public class MeITCase {
.assertType(s -> assertThat(s).isEqualTo(type))
.assertPasswordLinkDoesNotExists();
}
@Test
public void shouldGet403IfUserIsNotAdmin() {
String newUser = "user";
String password = "pass";
String type = "xml";
TestData.createUser(newUser, password, false, type);
ScmRequests.start()
.given()
.url(TestData.getMeUrl())
.usernameAndPassword(newUser, password)
.getMeResource()
.assertStatusCode(403);
}
}

View File

@@ -36,6 +36,7 @@ package sonia.scm.it;
import org.apache.http.HttpStatus;
import org.assertj.core.api.Assertions;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

View File

@@ -93,21 +93,4 @@ public class UserITCase {
.assertType(s -> assertThat(s).isEqualTo(type))
.assertPasswordLinkDoesNotExists();
}
@Test
public void shouldGet403IfUserIsNotAdmin() {
String newUser = "user";
String password = "pass";
String type = "xml";
TestData.createUser(newUser, password, false, type);
ScmRequests.start()
.given()
.url(TestData.getMeUrl())
.usernameAndPassword(newUser, password)
.getUserResource()
.assertStatusCode(403);
}
}

View File

@@ -24,6 +24,6 @@ public class RegExMatcher extends BaseMatcher<String> {
@Override
public boolean matches(Object o) {
return Pattern.compile(pattern).matcher(o.toString()).matches();
return o != null && Pattern.compile(pattern).matcher(o.toString()).matches();
}
}

View File

@@ -10,7 +10,7 @@ import static java.net.URI.create;
public class RestUtil {
public static final URI BASE_URL = create("http://localhost:8081/scm/");
public static final URI REST_BASE_URL = BASE_URL.resolve("api/rest/v2/");
public static final URI REST_BASE_URL = BASE_URL.resolve("api/v2/");
public static URI createResourceUrl(String path) {
return REST_BASE_URL.resolve(path);

View File

@@ -0,0 +1,40 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.plugin.Extension;
import sonia.scm.web.JsonEnricherBase;
import sonia.scm.web.JsonEnricherContext;
import javax.inject.Inject;
import javax.inject.Provider;
import static java.util.Collections.singletonMap;
import static sonia.scm.web.VndMediaType.INDEX;
@Extension
public class GitConfigInIndexResource extends JsonEnricherBase {
private final Provider<ScmPathInfoStore> scmPathInfoStore;
@Inject
public GitConfigInIndexResource(Provider<ScmPathInfoStore> scmPathInfoStore, ObjectMapper objectMapper) {
super(objectMapper);
this.scmPathInfoStore = scmPathInfoStore;
}
@Override
public void enrich(JsonEnricherContext context) {
if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) {
String gitConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), GitConfigResource.class)
.method("get")
.parameters()
.href();
JsonNode gitConfigRefNode = createObject(singletonMap("href", value(gitConfigUrl)));
addPropertyNode(context.getResponseEntity().get("_links"), "gitConfig", gitConfigRefNode);
}
}
}

View File

@@ -39,7 +39,6 @@ import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
@@ -51,8 +50,8 @@ import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------
@@ -130,27 +129,9 @@ public class GitChangesetConverter implements Closeable
*
* @throws IOException
*/
public Changeset createChangeset(RevCommit commit) throws IOException
public Changeset createChangeset(RevCommit commit)
{
List<String> branches = Lists.newArrayList();
Set<Ref> refs = repository.getAllRefsByPeeledObjectId().get(commit.getId());
if (Util.isNotEmpty(refs))
{
for (Ref ref : refs)
{
String branch = GitUtil.getBranch(ref);
if (branch != null)
{
branches.add(branch);
}
}
}
return createChangeset(commit, branches);
return createChangeset(commit, Collections.emptyList());
}
/**
@@ -165,7 +146,6 @@ public class GitChangesetConverter implements Closeable
* @throws IOException
*/
public Changeset createChangeset(RevCommit commit, String branch)
throws IOException
{
return createChangeset(commit, Lists.newArrayList(branch));
}
@@ -183,7 +163,6 @@ public class GitChangesetConverter implements Closeable
* @throws IOException
*/
public Changeset createChangeset(RevCommit commit, List<String> branches)
throws IOException
{
String id = commit.getId().name();
List<String> parentList = null;

View File

@@ -63,8 +63,11 @@ import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static java.util.Optional.of;
//~--- JDK imports ------------------------------------------------------------
/**
@@ -345,12 +348,11 @@ public final class GitUtil
*
* @throws IOException
*/
public static ObjectId getBranchId(org.eclipse.jgit.lib.Repository repo,
public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo,
String branchName)
throws IOException
{
ObjectId branchId = null;
Ref ref = null;
if (!branchName.startsWith(REF_HEAD))
{
branchName = PREFIX_HEADS.concat(branchName);
@@ -360,24 +362,19 @@ public final class GitUtil
try
{
Ref ref = repo.findRef(branchName);
ref = repo.findRef(branchName);
if (ref != null)
{
branchId = ref.getObjectId();
}
else if (logger.isWarnEnabled())
if (ref == null)
{
logger.warn("could not find branch for {}", branchName);
}
}
catch (IOException ex)
{
logger.warn("error occured during resolve of branch id", ex);
}
return branchId;
return ref;
}
/**
@@ -499,68 +496,48 @@ public final class GitUtil
return ref;
}
/**
* Method description
*
*
* @param repo
*
* @return
*
* @throws IOException
*/
public static ObjectId getRepositoryHead(org.eclipse.jgit.lib.Repository repo)
throws IOException
{
ObjectId id = null;
String head = null;
Map<String, Ref> refs = repo.getAllRefs();
public static ObjectId getRepositoryHead(org.eclipse.jgit.lib.Repository repo) {
return getRepositoryHeadRef(repo).map(Ref::getObjectId).orElse(null);
}
for (Map.Entry<String, Ref> e : refs.entrySet())
{
String key = e.getKey();
public static Optional<Ref> getRepositoryHeadRef(org.eclipse.jgit.lib.Repository repo) {
Optional<Ref> foundRef = findMostAppropriateHead(repo.getAllRefs());
if (REF_HEAD.equals(key))
{
head = REF_HEAD;
id = e.getValue().getObjectId();
break;
}
else if (key.startsWith(REF_HEAD_PREFIX))
{
id = e.getValue().getObjectId();
head = key.substring(REF_HEAD_PREFIX.length());
if (REF_MASTER.equals(head))
{
break;
}
if (foundRef.isPresent()) {
if (logger.isDebugEnabled()) {
logger.debug("use {}:{} as repository head for directory {}",
foundRef.map(GitUtil::getBranch).orElse(null),
foundRef.map(Ref::getObjectId).map(ObjectId::name).orElse(null),
repo.getDirectory());
}
} else {
logger.warn("could not find repository head in directory {}", repo.getDirectory());
}
if (id == null)
{
id = repo.resolve(Constants.HEAD);
return foundRef;
}
private static Optional<Ref> findMostAppropriateHead(Map<String, Ref> refs) {
Ref refHead = refs.get(REF_HEAD);
if (refHead != null && refHead.isSymbolic() && isBranch(refHead.getTarget().getName())) {
return of(refHead.getTarget());
}
if (logger.isDebugEnabled())
{
if ((head != null) && (id != null))
{
logger.debug("use {}:{} as repository head", head, id.name());
}
else if (id != null)
{
logger.debug("use {} as repository head", id.name());
}
else
{
logger.warn("could not find repository head");
}
Ref master = refs.get(REF_HEAD_PREFIX + REF_MASTER);
if (master != null) {
return of(master);
}
return id;
Ref develop = refs.get(REF_HEAD_PREFIX + "develop");
if (develop != null) {
return of(develop);
}
return refs.entrySet()
.stream()
.filter(e -> e.getKey().startsWith(REF_HEAD_PREFIX))
.map(Map.Entry::getValue)
.findFirst();
}
/**
@@ -648,7 +625,7 @@ public final class GitUtil
return tagName;
}
/**
* Method description
*

View File

@@ -34,19 +34,20 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import org.eclipse.jgit.lib.Repository;
//~--- JDK imports ------------------------------------------------------------
import java.io.IOException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitConstants;
import sonia.scm.repository.GitUtil;
import java.io.IOException;
import java.util.Optional;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
@@ -97,27 +98,29 @@ public class AbstractGitCommand
}
return commit;
}
protected ObjectId getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException {
ObjectId head;
if ( Strings.isNullOrEmpty(requestedBranch) ) {
head = getDefaultBranch(gitRepository);
} else {
head = GitUtil.getBranchId(gitRepository, requestedBranch);
}
return head;
}
protected ObjectId getDefaultBranch(Repository gitRepository) throws IOException {
ObjectId head;
String defaultBranchName = repository.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH);
if (!Strings.isNullOrEmpty(defaultBranchName)) {
head = GitUtil.getBranchId(gitRepository, defaultBranchName);
Ref ref = getBranchOrDefault(gitRepository, null);
if (ref == null) {
return null;
} else {
logger.trace("no default branch configured, use repository head as default");
head = GitUtil.getRepositoryHead(gitRepository);
return ref.getObjectId();
}
}
protected Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException {
if ( Strings.isNullOrEmpty(requestedBranch) ) {
String defaultBranchName = repository.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH);
if (!Strings.isNullOrEmpty(defaultBranchName)) {
return GitUtil.getBranchId(gitRepository, defaultBranchName);
} else {
logger.trace("no default branch configured, use repository head as default");
Optional<Ref> repositoryHeadRef = GitUtil.getRepositoryHeadRef(gitRepository);
return repositoryHeadRef.orElse(null);
}
} else {
return GitUtil.getBranchId(gitRepository, requestedBranch);
}
return head;
}
//~--- fields ---------------------------------------------------------------

View File

@@ -39,6 +39,7 @@ import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
@@ -170,8 +171,8 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
GitChangesetConverter converter = null;
RevWalk revWalk = null;
try (org.eclipse.jgit.lib.Repository gr = open()) {
if (!gr.getAllRefs().isEmpty()) {
try (org.eclipse.jgit.lib.Repository repository = open()) {
if (!repository.getAllRefs().isEmpty()) {
int counter = 0;
int start = request.getPagingStart();
@@ -188,18 +189,18 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
ObjectId startId = null;
if (!Strings.isNullOrEmpty(request.getStartChangeset())) {
startId = gr.resolve(request.getStartChangeset());
startId = repository.resolve(request.getStartChangeset());
}
ObjectId endId = null;
if (!Strings.isNullOrEmpty(request.getEndChangeset())) {
endId = gr.resolve(request.getEndChangeset());
endId = repository.resolve(request.getEndChangeset());
}
revWalk = new RevWalk(gr);
revWalk = new RevWalk(repository);
converter = new GitChangesetConverter(gr, revWalk);
converter = new GitChangesetConverter(repository, revWalk);
if (!Strings.isNullOrEmpty(request.getPath())) {
revWalk.setTreeFilter(
@@ -207,13 +208,13 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
PathFilter.create(request.getPath()), TreeFilter.ANY_DIFF));
}
ObjectId head = getBranchOrDefault(gr, request.getBranch());
Ref branch = getBranchOrDefault(repository,request.getBranch());
if (head != null) {
if (branch != null) {
if (startId != null) {
revWalk.markStart(revWalk.lookupCommit(startId));
} else {
revWalk.markStart(revWalk.lookupCommit(head));
revWalk.markStart(revWalk.lookupCommit(branch.getObjectId()));
}
Iterator<RevCommit> iterator = revWalk.iterator();
@@ -234,10 +235,14 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
}
}
changesets = new ChangesetPagingResult(counter, changesetList);
if (branch != null) {
changesets = new ChangesetPagingResult(counter, changesetList, GitUtil.getBranch(branch.getName()));
} else {
changesets = new ChangesetPagingResult(counter, changesetList);
}
} else if (logger.isWarnEnabled()) {
logger.warn("the repository {} seems to be empty",
repository.getName());
this.repository.getName());
changesets = new ChangesetPagingResult(0, Collections.EMPTY_LIST);
}

View File

@@ -1,5 +1,6 @@
//@flow
import React from "react";
import { repositories } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
@@ -10,14 +11,16 @@ class ProtocolInformation extends React.Component<Props> {
render() {
const { repository } = this.props;
if (!repository._links.httpProtocol) {
const href = repositories.getProtocolLinkByType(repository, "http");
if (!href) {
return null;
}
return (
<div>
<h4>Clone the repository</h4>
<pre>
<code>git clone {repository._links.httpProtocol.href}</code>
<code>git clone {href}</code>
</pre>
<h4>Create a new repository</h4>
<pre>
@@ -30,7 +33,7 @@ class ProtocolInformation extends React.Component<Props> {
<br />
git commit -m "added readme"
<br />
git remote add origin {repository._links.httpProtocol.href}
git remote add origin {href}
<br />
git push -u origin master
<br />
@@ -39,7 +42,7 @@ class ProtocolInformation extends React.Component<Props> {
<h4>Push an existing repository</h4>
<pre>
<code>
git remote add origin {repository._links.httpProtocol.href}
git remote add origin {href}
<br />
git push -u origin master
<br />

View File

@@ -0,0 +1,64 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.inject.util.Providers;
import org.junit.Rule;
import org.junit.Test;
import sonia.scm.web.JsonEnricherContext;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.core.MediaType;
import java.net.URI;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini")
public class GitConfigInIndexResourceTest {
@Rule
public final ShiroRule shiroRule = new ShiroRule();
private final ObjectMapper objectMapper = new ObjectMapper();
private final ObjectNode root = objectMapper.createObjectNode();
private final GitConfigInIndexResource gitConfigInIndexResource;
public GitConfigInIndexResourceTest() {
root.put("_links", objectMapper.createObjectNode());
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(() -> URI.create("/"));
gitConfigInIndexResource = new GitConfigInIndexResource(Providers.of(pathInfoStore), objectMapper);
}
@Test
@SubjectAware(username = "admin", password = "secret")
public void admin() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
gitConfigInIndexResource.enrich(context);
assertEquals("/v2/config/git", root.get("_links").get("gitConfig").get("href").asText());
}
@Test
@SubjectAware(username = "readOnly", password = "secret")
public void user() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
gitConfigInIndexResource.enrich(context);
assertFalse(root.get("_links").iterator().hasNext());
}
@Test
public void anonymous() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
gitConfigInIndexResource.enrich(context);
assertFalse(root.get("_links").iterator().hasNext());
}
}

View File

@@ -1,3 +1,4 @@
/**
* Copyright (c) 2010, Sebastian Sdorra
* All rights reserved.
@@ -33,14 +34,17 @@
package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.io.Files;
import org.junit.Test;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.GitConstants;
import sonia.scm.repository.Modifications;
import java.io.File;
import java.io.IOException;
import static java.nio.charset.Charset.defaultCharset;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -48,8 +52,6 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
//~--- JDK imports ------------------------------------------------------------
/**
* Unit tests for {@link GitLogCommand}.
*
@@ -72,6 +74,8 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
assertEquals("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1", result.getChangesets().get(1).getId());
assertEquals("592d797cd36432e591416e8b2b98154f4f163411", result.getChangesets().get(2).getId());
assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", result.getChangesets().get(3).getId());
assertEquals("master", result.getBranchName());
assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty()));
// set default branch and fetch again
repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
@@ -79,10 +83,12 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
result = createCommand().getChangesets(new LogCommandRequest());
assertNotNull(result);
assertEquals("test-branch", result.getBranchName());
assertEquals(3, result.getTotal());
assertEquals("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", result.getChangesets().get(0).getId());
assertEquals("592d797cd36432e591416e8b2b98154f4f163411", result.getChangesets().get(1).getId());
assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", result.getChangesets().get(2).getId());
assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty()));
}
@Test
@@ -210,6 +216,32 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", c2.getId());
}
@Test
public void shouldFindDefaultBranchFromHEAD() throws Exception {
setRepositoryHeadReference("ref: refs/heads/test-branch");
ChangesetPagingResult changesets = createCommand().getChangesets(new LogCommandRequest());
assertEquals("test-branch", changesets.getBranchName());
}
@Test
public void shouldFindMasterBranchWhenHEADisNoRef() throws Exception {
setRepositoryHeadReference("592d797cd36432e591416e8b2b98154f4f163411");
ChangesetPagingResult changesets = createCommand().getChangesets(new LogCommandRequest());
assertEquals("master", changesets.getBranchName());
}
private void setRepositoryHeadReference(String s) throws IOException {
Files.write(s, repositoryHeadReferenceFile(), defaultCharset());
}
private File repositoryHeadReferenceFile() {
return new File(repositoryDirectory, "HEAD");
}
private GitLogCommand createCommand()
{
return new GitLogCommand(createContext(), repository);

View File

@@ -2,8 +2,10 @@
readOnly = secret, reader
writeOnly = secret, writer
readWrite = secret, readerWriter
admin = secret, admin
[roles]
reader = configuration:read:git
writer = configuration:write:git
readerWriter = configuration:*:git
admin = *

View File

@@ -0,0 +1,40 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.plugin.Extension;
import sonia.scm.web.JsonEnricherBase;
import sonia.scm.web.JsonEnricherContext;
import javax.inject.Inject;
import javax.inject.Provider;
import static java.util.Collections.singletonMap;
import static sonia.scm.web.VndMediaType.INDEX;
@Extension
public class HgConfigInIndexResource extends JsonEnricherBase {
private final Provider<ScmPathInfoStore> scmPathInfoStore;
@Inject
public HgConfigInIndexResource(Provider<ScmPathInfoStore> scmPathInfoStore, ObjectMapper objectMapper) {
super(objectMapper);
this.scmPathInfoStore = scmPathInfoStore;
}
@Override
public void enrich(JsonEnricherContext context) {
if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) {
String hgConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), HgConfigResource.class)
.method("get")
.parameters()
.href();
JsonNode hgConfigRefNode = createObject(singletonMap("href", value(hgConfigUrl)));
addPropertyNode(context.getResponseEntity().get("_links"), "hgConfig", hgConfigRefNode);
}
}
}

View File

@@ -132,7 +132,11 @@ public class HgLogCommand extends AbstractCommand implements LogCommand
List<Changeset> changesets = on(repository).rev(start + ":"
+ end).execute();
result = new ChangesetPagingResult(total, changesets);
if (request.getBranch() == null) {
result = new ChangesetPagingResult(total, changesets);
} else {
result = new ChangesetPagingResult(total, changesets, request.getBranch());
}
}
else
{

View File

@@ -216,10 +216,7 @@ public abstract class AbstractChangesetCommand extends AbstractCommand
String branch = in.textUpTo('\n');
if (!BRANCH_DEFAULT.equals(branch))
{
changeset.getBranches().add(branch);
}
changeset.getBranches().add(branch);
String p1 = readId(in, changeset, PROPERTY_PARENT1_REVISION);

View File

@@ -1,5 +1,6 @@
//@flow
import React from "react";
import { repositories } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
@@ -10,14 +11,15 @@ class ProtocolInformation extends React.Component<Props> {
render() {
const { repository } = this.props;
if (!repository._links.httpProtocol) {
const href = repositories.getProtocolLinkByType(repository, "http");
if (!href) {
return null;
}
return (
<div>
<h4>Clone the repository</h4>
<pre>
<code>hg clone {repository._links.httpProtocol.href}</code>
<code>hg clone {href}</code>
</pre>
<h4>Create a new repository</h4>
<pre>
@@ -26,7 +28,7 @@ class ProtocolInformation extends React.Component<Props> {
<br />
echo "[paths]" > .hg/hgrc
<br />
echo "default = {repository._links.httpProtocol.href}" > .hg/hgrc
echo "default = {href}" > .hg/hgrc
<br />
echo "# {repository.name}" > README.md
<br />
@@ -44,7 +46,7 @@ class ProtocolInformation extends React.Component<Props> {
<code>
# add the repository url as default to your .hg/hgrc e.g:
<br />
default = {repository._links.httpProtocol.href}
default = {href}
<br />
# push to remote repository
<br />

View File

@@ -0,0 +1,64 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.inject.util.Providers;
import org.junit.Rule;
import org.junit.Test;
import sonia.scm.web.JsonEnricherContext;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.core.MediaType;
import java.net.URI;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini")
public class HgConfigInIndexResourceTest {
@Rule
public final ShiroRule shiroRule = new ShiroRule();
private final ObjectMapper objectMapper = new ObjectMapper();
private final ObjectNode root = objectMapper.createObjectNode();
private final HgConfigInIndexResource hgConfigInIndexResource;
public HgConfigInIndexResourceTest() {
root.put("_links", objectMapper.createObjectNode());
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(() -> URI.create("/"));
hgConfigInIndexResource = new HgConfigInIndexResource(Providers.of(pathInfoStore), objectMapper);
}
@Test
@SubjectAware(username = "admin", password = "secret")
public void admin() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
hgConfigInIndexResource.enrich(context);
assertEquals("/v2/config/hg", root.get("_links").get("hgConfig").get("href").asText());
}
@Test
@SubjectAware(username = "readOnly", password = "secret")
public void user() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
hgConfigInIndexResource.enrich(context);
assertFalse(root.get("_links").iterator().hasNext());
}
@Test
public void anonymous() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
hgConfigInIndexResource.enrich(context);
assertFalse(root.get("_links").iterator().hasNext());
}
}

View File

@@ -88,6 +88,21 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
result.getChangesets().get(2).getId());
}
@Test
public void testGetDefaultBranchInfo() {
LogCommandRequest request = new LogCommandRequest();
request.setPath("a.txt");
ChangesetPagingResult result = createComamnd().getChangesets(request);
assertNotNull(result);
assertEquals(1,
result.getChangesets().get(0).getBranches().size());
assertEquals("default",
result.getChangesets().get(0).getBranches().get(0));
}
@Test
public void testGetAllWithLimit() {
LogCommandRequest request = new LogCommandRequest();

View File

@@ -2,8 +2,10 @@
readOnly = secret, reader
writeOnly = secret, writer
readWrite = secret, readerWriter
admin = secret, admin
[roles]
reader = configuration:read:hg
writer = configuration:write:hg
readerWriter = configuration:*:hg
admin = *

View File

@@ -0,0 +1,40 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.plugin.Extension;
import sonia.scm.web.JsonEnricherBase;
import sonia.scm.web.JsonEnricherContext;
import javax.inject.Inject;
import javax.inject.Provider;
import static java.util.Collections.singletonMap;
import static sonia.scm.web.VndMediaType.INDEX;
@Extension
public class SvnConfigInIndexResource extends JsonEnricherBase {
private final Provider<ScmPathInfoStore> scmPathInfoStore;
@Inject
public SvnConfigInIndexResource(Provider<ScmPathInfoStore> scmPathInfoStore, ObjectMapper objectMapper) {
super(objectMapper);
this.scmPathInfoStore = scmPathInfoStore;
}
@Override
public void enrich(JsonEnricherContext context) {
if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) {
String svnConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), SvnConfigResource.class)
.method("get")
.parameters()
.href();
JsonNode svnConfigRefNode = createObject(singletonMap("href", value(svnConfigUrl)));
addPropertyNode(context.getResponseEntity().get("_links"), "svnConfig", svnConfigRefNode);
}
}
}

View File

@@ -32,122 +32,45 @@
package sonia.scm.web;
//~--- non-JDK imports --------------------------------------------------------
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.filter.GZipFilter;
import sonia.scm.filter.GZipFilterConfig;
import sonia.scm.filter.GZipResponseWrapper;
import sonia.scm.repository.Repository;
import sonia.scm.repository.SvnRepositoryHandler;
import sonia.scm.repository.spi.ScmProviderHttpServlet;
import sonia.scm.util.WebUtil;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
*/
public class SvnGZipFilter extends GZipFilter implements ScmProviderHttpServlet
{
class SvnGZipFilter implements ScmProviderHttpServlet {
private static final Logger logger = LoggerFactory.getLogger(SvnGZipFilter.class);
private final SvnRepositoryHandler handler;
private final ScmProviderHttpServlet delegate;
//~--- constructors ---------------------------------------------------------
private GZipFilterConfig config = new GZipFilterConfig();
/**
* Constructs ...
*
*
* @param handler
*/
public SvnGZipFilter(SvnRepositoryHandler handler, ScmProviderHttpServlet delegate)
{
SvnGZipFilter(SvnRepositoryHandler handler, ScmProviderHttpServlet delegate) {
this.handler = handler;
this.delegate = delegate;
}
//~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @param filterConfig
*
* @throws ServletException
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException
{
super.init(filterConfig);
getConfig().setBufferResponse(false);
}
/**
* Method description
*
*
* @param request
* @param response
* @param chain
*
* @throws IOException
* @throws ServletException
*/
@Override
protected void doFilter(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws IOException, ServletException
{
if (handler.getConfig().isEnabledGZip())
{
if (logger.isTraceEnabled())
{
logger.trace("encode svn request with gzip");
}
super.doFilter(request, response, chain);
}
else
{
if (logger.isTraceEnabled())
{
logger.trace("skip gzip encoding");
}
chain.doFilter(request, response);
}
config.setBufferResponse(false);
}
@Override
public void service(HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException {
if (handler.getConfig().isEnabledGZip())
{
if (logger.isTraceEnabled())
{
logger.trace("encode svn request with gzip");
}
super.doFilter(request, response, (servletRequest, servletResponse) -> delegate.service((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, repository));
}
else
{
if (logger.isTraceEnabled())
{
logger.trace("skip gzip encoding");
}
if (handler.getConfig().isEnabledGZip() && WebUtil.isGzipSupported(request)) {
logger.trace("compress svn response with gzip");
GZipResponseWrapper wrappedResponse = new GZipResponseWrapper(response, config);
delegate.service(request, wrappedResponse, repository);
wrappedResponse.finishResponse();
} else {
logger.trace("skip gzip encoding");
delegate.service(request, response, repository);
}
}

View File

@@ -1,5 +1,6 @@
//@flow
import React from "react";
import { repositories } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
@@ -10,14 +11,15 @@ class ProtocolInformation extends React.Component<Props> {
render() {
const { repository } = this.props;
if (!repository._links.httpProtocol) {
const href = repositories.getProtocolLinkByType(repository, "http");
if (!href) {
return null;
}
return (
<div>
<h4>Checkout the repository</h4>
<pre>
<code>svn checkout {repository._links.httpProtocol.href}</code>
<code>svn checkout {href}</code>
</pre>
</div>
);

View File

@@ -0,0 +1,64 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.inject.util.Providers;
import org.junit.Rule;
import org.junit.Test;
import sonia.scm.web.JsonEnricherContext;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.core.MediaType;
import java.net.URI;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini")
public class SvnConfigInIndexResourceTest {
@Rule
public final ShiroRule shiroRule = new ShiroRule();
private final ObjectMapper objectMapper = new ObjectMapper();
private final ObjectNode root = objectMapper.createObjectNode();
private final SvnConfigInIndexResource svnConfigInIndexResource;
public SvnConfigInIndexResourceTest() {
root.put("_links", objectMapper.createObjectNode());
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(() -> URI.create("/"));
svnConfigInIndexResource = new SvnConfigInIndexResource(Providers.of(pathInfoStore), objectMapper);
}
@Test
@SubjectAware(username = "admin", password = "secret")
public void admin() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
svnConfigInIndexResource.enrich(context);
assertEquals("/v2/config/svn", root.get("_links").get("svnConfig").get("href").asText());
}
@Test
@SubjectAware(username = "readOnly", password = "secret")
public void user() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
svnConfigInIndexResource.enrich(context);
assertFalse(root.get("_links").iterator().hasNext());
}
@Test
public void anonymous() {
JsonEnricherContext context = new JsonEnricherContext(URI.create("/index"), MediaType.valueOf(VndMediaType.INDEX), root);
svnConfigInIndexResource.enrich(context);
assertFalse(root.get("_links").iterator().hasNext());
}
}

View File

@@ -2,8 +2,10 @@
readOnly = secret, reader
writeOnly = secret, writer
readWrite = secret, readerWriter
admin = secret, admin
[roles]
reader = configuration:read:svn
writer = configuration:write:svn
readerWriter = configuration:*:svn
admin = *

View File

@@ -4,7 +4,8 @@
"private": true,
"scripts": {
"bootstrap": "lerna bootstrap",
"link": "lerna exec -- yarn link"
"link": "lerna exec -- yarn link",
"unlink": "lerna exec --no-bail -- yarn unlink"
},
"devDependencies": {
"lerna": "^3.2.1"

View File

@@ -0,0 +1,39 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
const styles = {
img: {
display: "block"
},
q: {
float: "left",
paddingLeft: "3px",
float: "right"
}
};
type Props = {
message: string,
classes: any
};
class Help extends React.Component<Props> {
render() {
const { message, classes } = this.props;
const multiline = message.length > 60 ? "is-tooltip-multiline" : "";
return (
<div
className={classNames("tooltip is-tooltip-right", multiline, classes.q)}
data-tooltip={message}
>
<i
className={classNames("fa fa-question has-text-info", classes.img)}
/>
</div>
);
}
}
export default injectSheet(styles)(Help);

View File

@@ -0,0 +1,46 @@
//@flow
import React from "react";
import { Help } from "./index";
type Props = {
label: string,
helpText?: string
};
class LabelWithHelpIcon extends React.Component<Props> {
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
renderHelp = () => {
const helpText = this.props.helpText;
if (helpText) {
return (
<div className="control columns is-vcentered">
<Help message={helpText} />
</div>
);
} else return null;
};
renderLabelWithHelpIcon = () => {
if (this.props.label) {
return (
<div className="field is-grouped">
<div className="control">{this.renderLabel()}</div>
{this.renderHelp()}
</div>
);
} else return null;
};
render() {
return this.renderLabelWithHelpIcon();
}
}
export default LabelWithHelpIcon;

View File

@@ -32,7 +32,7 @@ export function createUrl(url: string) {
if (url.indexOf("/") !== 0) {
urlWithStartingSlash = "/" + urlWithStartingSlash;
}
return `${contextPath}/api/rest/v2${urlWithStartingSlash}`;
return `${contextPath}/api/v2${urlWithStartingSlash}`;
}
class ApiClient {

View File

@@ -9,7 +9,7 @@ describe("create url", () => {
});
it("should add prefix for api", () => {
expect(createUrl("/users")).toBe("/api/rest/v2/users");
expect(createUrl("users")).toBe("/api/rest/v2/users");
expect(createUrl("/users")).toBe("/api/v2/users");
expect(createUrl("users")).toBe("/api/v2/users");
});
});

View File

@@ -9,7 +9,8 @@ type Props = {
disabled: boolean,
buttonLabel: string,
fieldLabel: string,
errorMessage: string
errorMessage: string,
helpText?: string
};
type State = {
@@ -25,7 +26,13 @@ class AddEntryToTableField extends React.Component<Props, State> {
}
render() {
const { disabled, buttonLabel, fieldLabel, errorMessage } = this.props;
const {
disabled,
buttonLabel,
fieldLabel,
errorMessage,
helpText
} = this.props;
return (
<div className="field">
<InputField
@@ -36,6 +43,7 @@ class AddEntryToTableField extends React.Component<Props, State> {
value={this.state.entryToAdd}
onReturnPressed={this.appendEntry}
disabled={disabled}
helpText={helpText}
/>
<AddButton
label={buttonLabel}

View File

@@ -1,11 +1,13 @@
//@flow
import React from "react";
import { Help } from "../index";
type Props = {
label?: string,
checked: boolean,
onChange?: boolean => void,
disabled?: boolean
disabled?: boolean,
helpText?: string
};
class Checkbox extends React.Component<Props> {
onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => {
@@ -14,9 +16,20 @@ class Checkbox extends React.Component<Props> {
}
};
renderHelp = () => {
const helpText = this.props.helpText;
if (helpText) {
return (
<div className="control columns is-vcentered">
<Help message={helpText} />
</div>
);
} else return null;
};
render() {
return (
<div className="field">
<div className="field is-grouped">
<div className="control">
<label className="checkbox" disabled={this.props.disabled}>
<input
@@ -28,6 +41,7 @@ class Checkbox extends React.Component<Props> {
{this.props.label}
</label>
</div>
{this.renderHelp()}
</div>
);
}

View File

@@ -1,6 +1,7 @@
//@flow
import React from "react";
import classNames from "classnames";
import { LabelWithHelpIcon } from "../index";
type Props = {
label?: string,
@@ -12,7 +13,8 @@ type Props = {
onReturnPressed?: () => void,
validationError: boolean,
errorMessage: string,
disabled?: boolean
disabled?: boolean,
helpText?: string
};
class InputField extends React.Component<Props> {
@@ -33,15 +35,6 @@ class InputField extends React.Component<Props> {
this.props.onChange(event.target.value);
};
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
const onReturnPressed = this.props.onReturnPressed;
if (!onReturnPressed) {
@@ -60,7 +53,9 @@ class InputField extends React.Component<Props> {
value,
validationError,
errorMessage,
disabled
disabled,
label,
helpText
} = this.props;
const errorView = validationError ? "is-danger" : "";
const helper = validationError ? (
@@ -70,7 +65,7 @@ class InputField extends React.Component<Props> {
);
return (
<div className="field">
{this.renderLabel()}
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<input
ref={input => {

View File

@@ -1,5 +1,7 @@
//@flow
import React from "react";
import classNames from "classnames";
import { LabelWithHelpIcon } from "../index";
export type SelectItem = {
value: string,
@@ -10,7 +12,9 @@ type Props = {
label?: string,
options: SelectItem[],
value?: SelectItem,
onChange: string => void
onChange: string => void,
loading?: boolean,
helpText?: string
};
class Select extends React.Component<Props> {
@@ -28,21 +32,18 @@ class Select extends React.Component<Props> {
this.props.onChange(event.target.value);
};
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
render() {
const { options, value } = this.props;
const { options, value, label, helpText, loading } = this.props;
const loadingClass = loading ? "is-loading" : "";
return (
<div className="field">
{this.renderLabel()}
<div className="control select">
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className={classNames(
"control select",
loadingClass
)}>
<select
ref={input => {
this.field = input;

View File

@@ -1,5 +1,6 @@
//@flow
import React from "react";
import { LabelWithHelpIcon } from "../index";
export type SelectItem = {
value: string,
@@ -10,7 +11,8 @@ type Props = {
label?: string,
placeholder?: SelectItem[],
value?: string,
onChange: string => void
onChange: string => void,
helpText?: string
};
class Textarea extends React.Component<Props> {
@@ -20,20 +22,12 @@ class Textarea extends React.Component<Props> {
this.props.onChange(event.target.value);
};
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
render() {
const { placeholder, value } = this.props;
const { placeholder, value, label, helpText } = this.props;
return (
<div className="field">
{this.renderLabel()}
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<textarea
className="textarea"

View File

@@ -2,8 +2,9 @@
import * as validation from "./validation.js";
import * as urls from "./urls";
import * as repositories from "./repositories.js";
export { validation, urls };
export { validation, urls, repositories };
export { default as DateFromNow } from "./DateFromNow.js";
export { default as ErrorNotification } from "./ErrorNotification.js";
@@ -15,6 +16,8 @@ export { default as MailLink } from "./MailLink.js";
export { default as Notification } from "./Notification.js";
export { default as Paginator } from "./Paginator.js";
export { default as ProtectedRoute } from "./ProtectedRoute.js";
export { default as Help } from "./Help.js";
export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon.js";
export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR } from "./apiclient.js";

View File

@@ -0,0 +1,19 @@
// @flow
import type { Repository } from "@scm-manager/ui-types";
// util methods for repositories
export function getProtocolLinkByType(repository: Repository, type: string) {
let protocols = repository._links.protocol;
if (protocols) {
if (!Array.isArray(protocols)) {
protocols = [protocols];
}
for (let proto of protocols) {
if (proto.name === type) {
return proto.href;
}
}
}
return null;
}

View File

@@ -0,0 +1,99 @@
// @flow
import type { Repository } from "@scm-manager/ui-types";
import { getProtocolLinkByType, getTypePredicate } from "./repositories";
describe("getProtocolLinkByType tests", () => {
it("should return the http protocol link", () => {
const repository: Repository = {
namespace: "scm",
name: "core",
type: "git",
_links: {
protocol: [{
name: "http",
href: "http://scm.scm-manager.org/repo/scm/core"
}]
}
};
const link = getProtocolLinkByType(repository, "http");
expect(link).toBe("http://scm.scm-manager.org/repo/scm/core");
});
it("should return the http protocol link from multiple protocols", () => {
const repository: Repository = {
namespace: "scm",
name: "core",
type: "git",
_links: {
protocol: [{
name: "http",
href: "http://scm.scm-manager.org/repo/scm/core"
},{
name: "ssh",
href: "git@scm.scm-manager.org:scm/core"
}]
}
};
const link = getProtocolLinkByType(repository, "http");
expect(link).toBe("http://scm.scm-manager.org/repo/scm/core");
});
it("should return the http protocol, even if the protocol is a single link", () => {
const repository: Repository = {
namespace: "scm",
name: "core",
type: "git",
_links: {
protocol: {
name: "http",
href: "http://scm.scm-manager.org/repo/scm/core"
}
}
};
const link = getProtocolLinkByType(repository, "http");
expect(link).toBe("http://scm.scm-manager.org/repo/scm/core");
});
it("should return null, if such a protocol does not exists", () => {
const repository: Repository = {
namespace: "scm",
name: "core",
type: "git",
_links: {
protocol: [{
name: "http",
href: "http://scm.scm-manager.org/repo/scm/core"
},{
name: "ssh",
href: "git@scm.scm-manager.org:scm/core"
}]
}
};
const link = getProtocolLinkByType(repository, "awesome");
expect(link).toBeNull();
});
it("should return null, if no protocols are available", () => {
const repository: Repository = {
namespace: "scm",
name: "core",
type: "git",
_links: {}
};
const link = getProtocolLinkByType(repository, "http");
expect(link).toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
// @flow
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
const nameRegex = /^[A-Za-z0-9\.\-_][A-Za-z0-9\.\-_@]*$/;
export const isNameValid = (name: string) => {
return nameRegex.test(name);

View File

@@ -5,6 +5,7 @@ describe("test name validation", () => {
it("should return false", () => {
// invalid names taken from ValidationUtilTest.java
const invalidNames = [
"@test",
" test 123",
" test 123 ",
"test 123 ",
@@ -35,10 +36,9 @@ describe("test name validation", () => {
"Test123-git",
"Test_user-123.git",
"test@scm-manager.de",
"test 123",
"test123",
"tt",
"t",
"valid_name",
"another1",
"stillValid",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
//@flow
import type { Links } from "./hal";
export type Permission = {
name: string,
type: string,
groupPermission: boolean,
_links?: Links
};
export type PermissionEntry = {
name: string,
type: string,
groupPermission: boolean
}
export type PermissionCollection = Permission[];

View File

@@ -1,9 +1,10 @@
// @flow
export type Link = {
href: string
href: string,
name?: string
};
export type Links = { [string]: Link };
export type Links = { [string]: Link | Link[] };
export type Collection = {
_embedded: Object,

View File

@@ -10,3 +10,5 @@ export type { Repository, RepositoryCollection, RepositoryGroup } from "./Reposi
export type { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes";
export type { Config } from "./Config";
export type { Permission, PermissionEntry, PermissionCollection } from "./RepositoryPermissions";

View File

@@ -48,6 +48,16 @@
<script>bootstrap</script>
</configuration>
</execution>
<execution>
<id>unlink</id>
<phase>package</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<script>unlink</script>
</configuration>
</execution>
<execution>
<id>link</id>
<phase>package</phase>

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
"@fortawesome/fontawesome-free": "^5.3.1",
"@scm-manager/ui-extensions": "^0.0.7",
"bulma": "^0.7.1",
"bulma-tooltip": "^2.0.2",
"classnames": "^2.2.5",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
@@ -15,6 +16,7 @@
"i18next-browser-languagedetector": "^2.2.2",
"i18next-fetch-backend": "^0.1.0",
"moment": "^2.22.2",
"node-sass": "^4.9.3",
"react": "^16.4.2",
"react-diff-view": "^1.7.0",
"react-dom": "^16.4.2",

View File

@@ -64,5 +64,29 @@
"login-attempt-limit-timeout-invalid": "This is not a number",
"login-attempt-limit-invalid": "This is not a number",
"plugin-url-invalid": "This is not a valid url"
},
"help": {
"realmDescriptionHelpText": "Enter authentication realm description",
"dateFormatHelpText": "Moments date format. Please have a look at the momentjs documentation.",
"pluginRepositoryHelpText": "The url of the plugin repository. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture",
"enableForwardingHelpText": "Enbale mod_proxy port forwarding.",
"enableRepositoryArchiveHelpText": "Enable repository archives. A complete page reload is required after a change of this value.",
"disableGroupingGridHelpText": "Disable repository Groups. A complete page reload is required after a change of this value.",
"allowAnonymousAccessHelpText": "Anonymous users have read access on public repositories.",
"skipFailedAuthenticatorsHelpText": "Do not stop the authentication chain, if an authenticator finds the user but fails to authenticate the user.",
"adminGroupsHelpText": "Names of groups with admin permissions.",
"adminUsersHelpText": "Names of users with admin permissions.",
"forceBaseUrlHelpText": "Redirects to the base url if the request comes from a other url",
"baseUrlHelpText": "The url of the application (with context path), i.e. http://localhost:8080/scm",
"loginAttemptLimitHelpText": "Maximum allowed login attempts. Use -1 to disable the login attempt limit.",
"loginAttemptLimitTimeoutHelpText": "Timeout in seconds for users which are temporary disabled, because of too many failed login attempts.",
"enableProxyHelpText": "Enable Proxy",
"proxyPortHelpText": "The proxy port",
"proxyPasswordHelpText": "The password for the proxy server authentication.",
"proxyServerHelpText": "The proxy server",
"proxyUserHelpText": "The username for the proxy server authentication.",
"proxyExcludesHelpText": "Glob patterns for hostnames which should be excluded from proxy settings.",
"enableXsrfProtectionHelpText": "Enable Xsrf Cookie Protection. Note: This feature is still experimental.",
"defaultNameSpaceStrategyHelpText": "The default namespace strategy"
}
}

View File

@@ -42,7 +42,12 @@
"group-form": {
"submit": "Submit",
"name-error": "Group name is invalid",
"description-error": "Description is invalid"
"description-error": "Description is invalid",
"help": {
"nameHelpText": "Unique name of the group",
"descriptionHelpText": "A short description of the group",
"memberHelpText": "Usernames of the group members"
}
},
"delete-group-button": {
"label": "Delete",

View File

@@ -22,7 +22,8 @@
"actions-label": "Actions",
"back-label": "Back",
"navigation-label": "Navigation",
"information": "Information"
"information": "Information",
"permissions": "Permissions"
},
"create": {
"title": "Create Repository",
@@ -42,5 +43,36 @@
"submit": "Yes",
"cancel": "No"
}
},
"permission": {
"error-title": "Error",
"error-subtitle": "Unknown permissions error",
"name": "User or Group",
"type": "Type",
"group-permission": "Group Permission",
"edit-permission": {
"delete-button": "Delete",
"save-button": "Save Changes"
},
"delete-permission-button": {
"label": "Delete",
"confirm-alert": {
"title": "Delete permission",
"message": "Do you really want to delete the permission?",
"submit": "Yes",
"cancel": "No"
}
},
"add-permission": {
"add-permission-heading": "Add new Permission",
"submit-button": "Submit",
"name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!"
}
},
"help": {
"nameHelpText": "The name of the repository. This name will be part of the repository url.",
"typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).",
"contactHelpText": "Email address of the person who is responsible for this repository.",
"descriptionHelpText": "A short description of the repository."
}
}

View File

@@ -51,5 +51,14 @@
"password-invalid": "Password has to be between 6 and 32 characters",
"passwordValidation-invalid": "Passwords have to be the same",
"validatePassword": "Please validate password here"
},
"help": {
"usernameHelpText": "Unique name of the user.",
"displayNameHelpText": "Display name of the user.",
"mailHelpText": "Email address of the user.",
"passwordHelpText": "Plain text password of the user.",
"passwordConfirmHelpText": "Repeat the password for validation.",
"adminHelpText": "An administrator is able to create, modify and delete repositories, groups and users.",
"activeHelpText": "Activate or deactive the user."
}
}

View File

@@ -23,12 +23,14 @@ class BaseUrlSettings extends React.Component<Props> {
label={t("base-url-settings.force-base-url")}
onChange={this.handleForceBaseUrlChange}
disabled={!hasUpdatePermission}
helpText={t("help.forceBaseUrlHelpText")}
/>
<InputField
label={t("base-url-settings.base-url")}
onChange={this.handleBaseUrlChange}
value={baseUrl}
disabled={!hasUpdatePermission}
helpText={t("help.baseUrlHelpText")}
/>
</div>
);

View File

@@ -41,54 +41,63 @@ class GeneralSettings extends React.Component<Props> {
onChange={this.handleRealmDescriptionChange}
value={realmDescription}
disabled={!hasUpdatePermission}
helpText={t("help.realmDescriptionHelpText")}
/>
<InputField
label={t("general-settings.date-format")}
onChange={this.handleDateFormatChange}
value={dateFormat}
disabled={!hasUpdatePermission}
helpText={t("help.dateFormatHelpText")}
/>
<InputField
label={t("general-settings.plugin-url")}
onChange={this.handlePluginUrlChange}
value={pluginUrl}
disabled={!hasUpdatePermission}
helpText={t("help.pluginRepositoryHelpText")}
/>
<InputField
label={t("general-settings.default-namespace-strategy")}
onChange={this.handleDefaultNamespaceStrategyChange}
value={defaultNamespaceStrategy}
disabled={!hasUpdatePermission}
helpText={t("help.defaultNameSpaceStrategyHelpText")}
/>
<Checkbox
checked={enabledXsrfProtection}
label={t("general-settings.enabled-xsrf-protection")}
onChange={this.handleEnabledXsrfProtectionChange}
disabled={!hasUpdatePermission}
helpText={t("help.enableXsrfProtectionHelpText")}
/>
<Checkbox
checked={enableRepositoryArchive}
label={t("general-settings.enable-repository-archive")}
onChange={this.handleEnableRepositoryArchiveChange}
disabled={!hasUpdatePermission}
helpText={t("help.enableRepositoryArchiveHelpText")}
/>
<Checkbox
checked={disableGroupingGrid}
label={t("general-settings.disable-grouping-grid")}
onChange={this.handleDisableGroupingGridChange}
disabled={!hasUpdatePermission}
helpText={t("help.disableGroupingGridHelpText")}
/>
<Checkbox
checked={anonymousAccessEnabled}
label={t("general-settings.anonymous-access-enabled")}
onChange={this.handleAnonymousAccessEnabledChange}
disabled={!hasUpdatePermission}
helpText={t("help.allowAnonymousAccessHelpText")}
/>
<Checkbox
checked={skipFailedAuthenticators}
label={t("general-settings.skip-failed-authenticators")}
onChange={this.handleSkipFailedAuthenticatorsChange}
disabled={!hasUpdatePermission}
helpText={t("help.skipFailedAuthenticatorsHelpText")}
/>
</div>
);

View File

@@ -47,6 +47,7 @@ class LoginAttempt extends React.Component<Props, State> {
disabled={!hasUpdatePermission}
validationError={this.state.loginAttemptLimitError}
errorMessage={t("validation.login-attempt-limit-invalid")}
helpText={t("help.loginAttemptLimitHelpText")}
/>
<InputField
label={t("login-attempt.login-attempt-limit-timeout")}
@@ -55,6 +56,7 @@ class LoginAttempt extends React.Component<Props, State> {
disabled={!hasUpdatePermission}
validationError={this.state.loginAttemptLimitTimeoutError}
errorMessage={t("validation.login-attempt-limit-timeout-invalid")}
helpText={t("help.loginAttemptLimitTimeoutHelpText")}
/>
</div>
);

View File

@@ -42,6 +42,7 @@ class ProxySettings extends React.Component<Props> {
label={t("proxy-settings.enable-proxy")}
onChange={this.handleEnableProxyChange}
disabled={!hasUpdatePermission}
helpText={t("help.enableProxyHelpText")}
/>
<InputField
label={t("proxy-settings.proxy-password")}
@@ -49,24 +50,28 @@ class ProxySettings extends React.Component<Props> {
value={proxyPassword}
type="password"
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyPasswordHelpText")}
/>
<InputField
label={t("proxy-settings.proxy-port")}
value={proxyPort}
onChange={this.handleProxyPortChange}
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyPortHelpText")}
/>
<InputField
label={t("proxy-settings.proxy-server")}
value={proxyServer}
onChange={this.handleProxyServerChange}
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyServerHelpText")}
/>
<InputField
label={t("proxy-settings.proxy-user")}
value={proxyUser}
onChange={this.handleProxyUserChange}
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyUserHelpText")}
/>
<ProxyExcludesTable
proxyExcludes={proxyExcludes}

View File

@@ -24,6 +24,7 @@ class AdminGroupTable extends React.Component<Props, State> {
removeLabel={t("admin-settings.remove-group-button")}
onRemove={this.removeEntry}
disabled={disabled}
helpText={t("help.adminGroupsHelpText")}
/>
);
}

View File

@@ -22,6 +22,7 @@ class AdminUserTable extends React.Component<Props> {
removeLabel={t("admin-settings.remove-user-button")}
onRemove={this.removeEntry}
disabled={disabled}
helpText={t("help.adminUsersHelpText")}
/>
);
}

View File

@@ -1,21 +1,22 @@
//@flow
import React from "react";
import { RemoveEntryOfTableButton } from "@scm-manager/ui-components";
import { RemoveEntryOfTableButton, LabelWithHelpIcon } from "@scm-manager/ui-components";
type Props = {
items: string[],
label: string,
removeLabel: string,
onRemove: (string[], string) => void,
disabled: boolean
disabled: boolean,
helpText: string
};
class ArrayConfigTable extends React.Component<Props> {
render() {
const { label, disabled, removeLabel, items } = this.props;
const { label, disabled, removeLabel, items, helpText } = this.props;
return (
<div>
<label className="label">{label}</label>
<LabelWithHelpIcon label={label} helpText={helpText}/>
<table className="table is-hoverable is-fullwidth">
<tbody>
{items.map(item => {

View File

@@ -22,6 +22,7 @@ class ProxyExcludesTable extends React.Component<Props, State> {
removeLabel={t("proxy-settings.remove-proxy-exclude-button")}
onRemove={this.removeEntry}
disabled={disabled}
helpText={t("help.proxyExcludesHelpText")}
/>
);
}

View File

@@ -23,7 +23,7 @@ import reducer, {
getConfigUpdatePermission
} from "./config";
const CONFIG_URL = "/api/rest/v2/config";
const CONFIG_URL = "/api/v2/config";
const error = new Error("You have an error!");
@@ -51,8 +51,8 @@ const config = {
enabledXsrfProtection: true,
defaultNamespaceStrategy: "sonia.scm.repository.DefaultNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/api/rest/v2/config" },
update: { href: "http://localhost:8081/api/rest/v2/config" }
self: { href: "http://localhost:8081/api/v2/config" },
update: { href: "http://localhost:8081/api/v2/config" }
}
};
@@ -80,8 +80,8 @@ const configWithNullValues = {
enabledXsrfProtection: true,
defaultNamespaceStrategy: "sonia.scm.repository.DefaultNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/api/rest/v2/config" },
update: { href: "http://localhost:8081/api/rest/v2/config" }
self: { href: "http://localhost:8081/api/v2/config" },
update: { href: "http://localhost:8081/api/v2/config" }
}
};
@@ -135,7 +135,7 @@ describe("config fetch()", () => {
});
it("should successfully modify config", () => {
fetchMock.putOnce("http://localhost:8081/api/rest/v2/config", {
fetchMock.putOnce("http://localhost:8081/api/v2/config", {
status: 204
});
@@ -150,7 +150,7 @@ describe("config fetch()", () => {
});
it("should call the callback after modifying config", () => {
fetchMock.putOnce("http://localhost:8081/api/rest/v2/config", {
fetchMock.putOnce("http://localhost:8081/api/v2/config", {
status: 204
});
@@ -169,7 +169,7 @@ describe("config fetch()", () => {
});
it("should fail modifying config on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/api/rest/v2/config", {
fetchMock.putOnce("http://localhost:8081/api/v2/config", {
status: 500
});

View File

@@ -11,6 +11,7 @@ import groups from "./groups/modules/groups";
import auth from "./modules/auth";
import pending from "./modules/pending";
import failure from "./modules/failure";
import permissions from "./repos/permissions/modules/permissions";
import config from "./config/modules/config";
import type { BrowserHistory } from "history/createBrowserHistory";
@@ -26,6 +27,7 @@ function createReduxStore(history: BrowserHistory) {
users,
repos,
repositoryTypes,
permissions,
groups,
auth,
config

View File

@@ -80,6 +80,7 @@ class GroupForm extends React.Component<Props, State> {
onChange={this.handleGroupNameChange}
value={group.name}
validationError={this.state.nameValidationError}
helpText={t("group-form.help.nameHelpText")}
/>
);
}
@@ -93,6 +94,7 @@ class GroupForm extends React.Component<Props, State> {
onChange={this.handleDescriptionChange}
value={group.description}
validationError={false}
helpText={t("group-form.help.descriptionHelpText")}
/>
<MemberNameTable
members={this.state.group.members}

View File

@@ -1,7 +1,10 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { RemoveEntryOfTableButton } from "@scm-manager/ui-components";
import {
RemoveEntryOfTableButton,
LabelWithHelpIcon
} from "@scm-manager/ui-components";
type Props = {
members: string[],
@@ -16,7 +19,10 @@ class MemberNameTable extends React.Component<Props, State> {
const { t } = this.props;
return (
<div>
<label className="label">{t("group.members")}</label>
<LabelWithHelpIcon
label={t("group.members")}
helpText={t("group-form.help.memberHelpText")}
/>
<table className="table is-hoverable is-fullwidth">
<tbody>
{this.props.members.map(member => {

View File

@@ -44,7 +44,7 @@ import reducer, {
MODIFY_GROUP_SUCCESS,
MODIFY_GROUP_FAILURE
} from "./groups";
const GROUPS_URL = "/api/rest/v2/groups";
const GROUPS_URL = "/api/v2/groups";
const error = new Error("You have an error!");
@@ -57,13 +57,13 @@ const humanGroup = {
members: ["userZaphod"],
_links: {
self: {
href: "http://localhost:8081/api/rest/v2/groups/humanGroup"
href: "http://localhost:8081/api/v2/groups/humanGroup"
},
delete: {
href: "http://localhost:8081/api/rest/v2/groups/humanGroup"
href: "http://localhost:8081/api/v2/groups/humanGroup"
},
update: {
href:"http://localhost:8081/api/rest/v2/groups/humanGroup"
href:"http://localhost:8081/api/v2/groups/humanGroup"
}
},
_embedded: {
@@ -72,7 +72,7 @@ const humanGroup = {
name: "userZaphod",
_links: {
self: {
href: "http://localhost:8081/api/rest/v2/users/userZaphod"
href: "http://localhost:8081/api/v2/users/userZaphod"
}
}
}
@@ -89,13 +89,13 @@ const emptyGroup = {
members: [],
_links: {
self: {
href: "http://localhost:8081/api/rest/v2/groups/emptyGroup"
href: "http://localhost:8081/api/v2/groups/emptyGroup"
},
delete: {
href: "http://localhost:8081/api/rest/v2/groups/emptyGroup"
href: "http://localhost:8081/api/v2/groups/emptyGroup"
},
update: {
href:"http://localhost:8081/api/rest/v2/groups/emptyGroup"
href:"http://localhost:8081/api/v2/groups/emptyGroup"
}
},
_embedded: {
@@ -108,16 +108,16 @@ const responseBody = {
pageTotal: 1,
_links: {
self: {
href: "http://localhost:3000/api/rest/v2/groups/?page=0&pageSize=10"
href: "http://localhost:3000/api/v2/groups/?page=0&pageSize=10"
},
first: {
href: "http://localhost:3000/api/rest/v2/groups/?page=0&pageSize=10"
href: "http://localhost:3000/api/v2/groups/?page=0&pageSize=10"
},
last: {
href: "http://localhost:3000/api/rest/v2/groups/?page=0&pageSize=10"
href: "http://localhost:3000/api/v2/groups/?page=0&pageSize=10"
},
create: {
href: "http://localhost:3000/api/rest/v2/groups/"
href: "http://localhost:3000/api/v2/groups/"
}
},
_embedded: {
@@ -244,7 +244,7 @@ describe("groups fetch()", () => {
});
it("should successfully modify group", () => {
fetchMock.putOnce("http://localhost:8081/api/rest/v2/groups/humanGroup", {
fetchMock.putOnce("http://localhost:8081/api/v2/groups/humanGroup", {
status: 204
});
@@ -259,7 +259,7 @@ describe("groups fetch()", () => {
});
it("should call the callback after modifying group", () => {
fetchMock.putOnce("http://localhost:8081/api/rest/v2/groups/humanGroup", {
fetchMock.putOnce("http://localhost:8081/api/v2/groups/humanGroup", {
status: 204
});
@@ -278,7 +278,7 @@ describe("groups fetch()", () => {
});
it("should fail modifying group on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/api/rest/v2/groups/humanGroup", {
fetchMock.putOnce("http://localhost:8081/api/v2/groups/humanGroup", {
status: 500
});
@@ -293,7 +293,7 @@ describe("groups fetch()", () => {
});
it("should delete successfully group humanGroup", () => {
fetchMock.deleteOnce("http://localhost:8081/api/rest/v2/groups/humanGroup", {
fetchMock.deleteOnce("http://localhost:8081/api/v2/groups/humanGroup", {
status: 204
});
@@ -308,7 +308,7 @@ describe("groups fetch()", () => {
});
it("should call the callback, after successful delete", () => {
fetchMock.deleteOnce("http://localhost:8081/api/rest/v2/groups/humanGroup", {
fetchMock.deleteOnce("http://localhost:8081/api/v2/groups/humanGroup", {
status: 204
});
@@ -324,7 +324,7 @@ describe("groups fetch()", () => {
});
it("should fail to delete group humanGroup", () => {
fetchMock.deleteOnce("http://localhost:8081/api/rest/v2/groups/humanGroup", {
fetchMock.deleteOnce("http://localhost:8081/api/v2/groups/humanGroup", {
status: 500
});

View File

@@ -78,7 +78,7 @@ describe("auth actions", () => {
});
it("should dispatch login success and dispatch fetch me", () => {
fetchMock.postOnce("/api/rest/v2/auth/access_token", {
fetchMock.postOnce("/api/v2/auth/access_token", {
body: {
cookie: true,
grant_type: "password",
@@ -88,7 +88,7 @@ describe("auth actions", () => {
headers: { "content-type": "application/json" }
});
fetchMock.getOnce("/api/rest/v2/me", {
fetchMock.getOnce("/api/v2/me", {
body: me,
headers: { "content-type": "application/json" }
});
@@ -106,7 +106,7 @@ describe("auth actions", () => {
});
it("should dispatch login failure", () => {
fetchMock.postOnce("/api/rest/v2/auth/access_token", {
fetchMock.postOnce("/api/v2/auth/access_token", {
status: 400
});
@@ -120,7 +120,7 @@ describe("auth actions", () => {
});
it("should dispatch fetch me success", () => {
fetchMock.getOnce("/api/rest/v2/me", {
fetchMock.getOnce("/api/v2/me", {
body: me,
headers: { "content-type": "application/json" }
});
@@ -141,7 +141,7 @@ describe("auth actions", () => {
});
it("should dispatch fetch me failure", () => {
fetchMock.getOnce("/api/rest/v2/me", {
fetchMock.getOnce("/api/v2/me", {
status: 500
});
@@ -155,7 +155,7 @@ describe("auth actions", () => {
});
it("should dispatch fetch me unauthorized", () => {
fetchMock.getOnce("/api/rest/v2/me", {
fetchMock.getOnce("/api/v2/me", {
status: 401
});
@@ -173,11 +173,11 @@ describe("auth actions", () => {
});
it("should dispatch logout success", () => {
fetchMock.deleteOnce("/api/rest/v2/auth/access_token", {
fetchMock.deleteOnce("/api/v2/auth/access_token", {
status: 204
});
fetchMock.getOnce("/api/rest/v2/me", {
fetchMock.getOnce("/api/v2/me", {
status: 401
});
@@ -194,7 +194,7 @@ describe("auth actions", () => {
});
it("should dispatch logout failure", () => {
fetchMock.deleteOnce("/api/rest/v2/auth/access_token", {
fetchMock.deleteOnce("/api/v2/auth/access_token", {
status: 500
});

View File

@@ -13,6 +13,20 @@ function extractIdentifierFromFailure(action: Action) {
return identifier;
}
function removeAllEntriesOfIdentifierFromState(
state: Object,
payload: any,
identifier: string
) {
const newState = {};
for (let failureType in state) {
if (failureType !== identifier && !failureType.startsWith(identifier)) {
newState[failureType] = state[failureType];
}
}
return newState;
}
function removeFromState(state: Object, identifier: string) {
const newState = {};
for (let failureType in state) {
@@ -47,7 +61,9 @@ export default function reducer(
if (action.itemId) {
identifier += "/" + action.itemId;
}
return removeFromState(state, identifier);
if (action.payload)
return removeAllEntriesOfIdentifierFromState(state, action.payload, identifier);
else return removeFromState(state, identifier);
}
}
return state;

View File

@@ -19,6 +19,20 @@ function removeFromState(state: Object, identifier: string) {
return newState;
}
function removeAllEntriesOfIdentifierFromState(
state: Object,
payload: any,
identifier: string
) {
const newState = {};
for (let childType in state) {
if (childType !== identifier && !childType.startsWith(identifier)) {
newState[childType] = state[childType];
}
}
return newState;
}
function extractIdentifierFromPending(action: Action) {
const type = action.type;
let identifier = type.substring(0, type.length - PENDING_SUFFIX.length);
@@ -48,7 +62,10 @@ export default function reducer(
if (action.itemId) {
identifier += "/" + action.itemId;
}
return removeFromState(state, identifier);
if (action.payload)
return removeAllEntriesOfIdentifierFromState(state, action.payload, identifier);
else
return removeFromState(state, identifier);
}
}
}

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
permissionUrl: string,
t: string => string,
repository: Repository
};
class PermissionsNavLink extends React.Component<Props> {
hasPermissionsLink = () => {
return this.props.repository._links.permissions;
};
render() {
if (!this.hasPermissionsLink()) {
return null;
}
const { permissionUrl, t } = this.props;
return (
<NavLink to={permissionUrl} label={t("repository-root.permissions")} />
);
}
}
export default translate("repos")(PermissionsNavLink);

View File

@@ -0,0 +1,39 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import PermissionsNavLink from "./PermissionsNavLink";
import EditNavLink from "./EditNavLink";
describe("PermissionsNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
permissions: {
href: "/permissions"
}
}
};
const navLink = mount(
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe("repository-root.permissions");
});
});

View File

@@ -90,12 +90,14 @@ class RepositoryForm extends React.Component<Props, State> {
value={repository ? repository.contact : ""}
validationError={this.state.contactValidationError}
errorMessage={t("validation.contact-invalid")}
helpText={t("help.contactHelpText")}
/>
<Textarea
label={t("repository.description")}
onChange={this.handleDescriptionChange}
value={repository ? repository.description : ""}
helpText={t("help.descriptionHelpText")}
/>
<SubmitButton
disabled={!this.isValid()}
@@ -129,12 +131,14 @@ class RepositoryForm extends React.Component<Props, State> {
value={repository ? repository.name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.name-invalid")}
helpText={t("help.nameHelpText")}
/>
<Select
label={t("repository.type")}
onChange={this.handleTypeChange}
value={repository ? repository.type : ""}
options={this.createSelectOptions(repositoryTypes)}
helpText={t("help.typeHelpText")}
/>
</div>
);

View File

@@ -22,9 +22,11 @@ import { translate } from "react-i18next";
import RepositoryDetails from "../components/RepositoryDetails";
import DeleteNavAction from "../components/DeleteNavAction";
import Edit from "../containers/Edit";
import Permissions from "../permissions/containers/Permissions";
import type { History } from "history";
import EditNavLink from "../components/EditNavLink";
import PermissionsNavLink from "../components/PermissionsNavLink";
import ScmDiff from "./ScmDiff";
type Props = {
@@ -112,11 +114,24 @@ class RepositoryRoot extends React.Component<Props> {
/>
)}
/>
<Route
path={`${url}/permissions`}
render={props => (
<Permissions
namespace={this.props.repository.namespace}
repoName={this.props.repository.name}
/>
)}
/>
</div>
<div className="column">
<Navigation>
<Section label={t("repository-root.navigation-label")}>
<NavLink to={url} label={t("repository-root.information")} />
<PermissionsNavLink
permissionUrl={`${url}/permissions`}
repository={repository}
/>
<EditNavLink repository={repository} editUrl={`${url}/edit`} />
</Section>
<Section label={t("repository-root.actions-label")}>

Some files were not shown because too many files have changed in this diff Show More