This commit is contained in:
Eduard Heimbuch
2020-09-21 08:43:56 +02:00
62 changed files with 1023 additions and 272 deletions

View File

@@ -6,7 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Tags overview for repository [#1331](https://github.com/scm-manager/scm-manager/pull/1331)
- Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335))
### Fixed
- Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328))
- Missing BranchCreatedEvent for mercurial ([#1334](https://github.com/scm-manager/scm-manager/pull/1334))

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -6,6 +6,7 @@ partiallyActive: true
Der Bereich Repository umfasst alles auf Basis von Repositories in Namespaces. Dazu zählen alle Operationen auf Branches, der Code und Einstellungen.
* [Branches](branches/)
* [Tags](tags/)
* [Code](code/)
* [Einstellungen](settings/)
<!--- AppendLinkContentEnd -->

13
docs/de/user/repo/tags.md Normal file
View File

@@ -0,0 +1,13 @@
---
title: Repository
subtitle: Tags
---
### Übersicht
Auf der Tags-Übersicht sind die existierenden Tags nach Erstelldatum absteigend aufgeführt. Bei einem Klick auf einen Tag wird der Benutzer zur Detailseite des Tags weitergeleitet.
![Tags Übersicht](assets/repository-tags-overview.png)
### Tag Detailseite
Hier wird ein Befehl zum Arbeiten mit dem Tag auf einer Kommandozeile aufgeführt.
![Tag Detailseite](assets/repository-tag-detailView.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -5,6 +5,7 @@ partiallyActive: true
The Repository area includes everything based on repositories in namespaces. This includes all operations on branches, the code and settings.
* [Branches](branches/)
* [Tags](tags/)
* [Code](code/)
* [Settings](settings/)

13
docs/en/user/repo/tags.md Normal file
View File

@@ -0,0 +1,13 @@
---
title: Repository
subtitle: Tags
---
### Overview
The tag overview shows the tags that exist for this repository. By clicking on a tag, the details page of the tag is shown.
![Tags Overview](assets/repository-tags-overview.png)
### Tag Details Page
This page shows a command to work with the tag on the command line.
![Tag Details Page](assets/repository-tag-detailView.png)

View File

@@ -109,6 +109,14 @@ public class Namespace implements PermissionObject, Cloneable {
.toHashCode();
}
@Override
public String toString() {
return "Namespace{" +
"namespace='" + namespace + '\'' +
", permissions=" + permissions +
'}';
}
@Override
public Namespace clone() {
try {

View File

@@ -27,6 +27,13 @@ package sonia.scm.repository;
import java.util.Collection;
import java.util.Optional;
/**
* Manages namespaces. Mind that namespaces do not have a lifecycle on their own, but only do exist through
* repositories. Therefore you cannot create or delete namespaces, but just change related settings like permissions
* associated with them.
*
* @since 2.6.0
*/
public interface NamespaceManager {
/**

View File

@@ -119,11 +119,16 @@ public class RepositoryPermission implements PermissionObject, Serializable
final RepositoryPermission other = (RepositoryPermission) obj;
return Objects.equal(name, other.name)
&& (verbs == null && other.verbs == null || verbs != null && other.verbs != null && CollectionUtils.isEqualCollection(verbs, other.verbs))
&& equalVerbs(other)
&& Objects.equal(role, other.role)
&& Objects.equal(groupPermission, other.groupPermission);
}
public boolean equalVerbs(RepositoryPermission other) {
return verbs == null && other.verbs == null
|| verbs != null && other.verbs != null && CollectionUtils.isEqualCollection(verbs, other.verbs);
}
/**
* Returns the hash code value for the {@link RepositoryPermission}.
*

View File

@@ -0,0 +1,48 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Tag } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
type Props = {
tag: Tag;
};
const GitTagInformation: FC<Props> = ({ tag }) => {
const [t] = useTranslation("plugins");
return (
<>
<h4>{t("scm-git-plugin.information.checkoutTag")}</h4>
<pre>
<code>
git checkout tags/{tag.name} -b branch/{tag.name}
</code>
</pre>
</>
);
};
export default GitTagInformation;

View File

@@ -32,6 +32,7 @@ import GitGlobalConfiguration from "./GitGlobalConfiguration";
import GitBranchInformation from "./GitBranchInformation";
import GitMergeInformation from "./GitMergeInformation";
import RepositoryConfig from "./RepositoryConfig";
import GitTagInformation from "./GitTagInformation";
// repository
@@ -42,6 +43,7 @@ export const gitPredicate = (props: any) => {
binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate);
binder.bind("repos.branch-details.information", GitBranchInformation, gitPredicate);
binder.bind("repos.tag-details.information", GitTagInformation, gitPredicate);
binder.bind("repos.repository-merge.information", GitMergeInformation, gitPredicate);
binder.bind("repos.repository-avatar", GitAvatar, gitPredicate);

View File

@@ -6,6 +6,7 @@
"replace": "Ein bestehendes Repository aktualisieren",
"fetch": "Remote-Änderungen herunterladen",
"checkout": "Branch wechseln",
"checkoutTag": "Tag als neuen Branch auschecken",
"merge": {
"heading": "Merge des Source Branch in den Target Branch",
"checkout": "1. Sicherstellen, dass der Workspace aufgeräumt ist und der Target Branch ausgecheckt wurde.",

View File

@@ -6,6 +6,7 @@
"replace": "Push an existing repository",
"fetch": "Get remote changes",
"checkout": "Switch branch",
"checkoutTag": "Checkout tag as new branch",
"merge": {
"heading": "How to merge source branch into target branch",
"checkout": "1. Make sure your workspace is clean and checkout target branch",

View File

@@ -0,0 +1,46 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Tag } from "@scm-manager/ui-types";
type Props = {
tag: Tag;
};
const HgTagInformation: FC<Props> = ({ tag }) => {
const [t] = useTranslation("plugins");
return (
<>
<h4>{t("scm-hg-plugin.information.checkoutTag")}</h4>
<pre>
<code>hg update {tag.name}</code>
</pre>
</>
);
};
export default HgTagInformation;

View File

@@ -28,6 +28,7 @@ import HgAvatar from "./HgAvatar";
import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components";
import HgGlobalConfiguration from "./HgGlobalConfiguration";
import HgBranchInformation from "./HgBranchInformation";
import HgTagInformation from "./HgTagInformation";
const hgPredicate = (props: any) => {
return props.repository && props.repository.type === "hg";
@@ -35,6 +36,7 @@ const hgPredicate = (props: any) => {
binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate);
binder.bind("repos.branch-details.information", HgBranchInformation, hgPredicate);
binder.bind("repos.tag-details.information", HgTagInformation, hgPredicate);
binder.bind("repos.repository-avatar", HgAvatar, hgPredicate);
// bind global configuration

View File

@@ -5,7 +5,8 @@
"create" : "Neues Repository erstellen",
"replace" : "Ein bestehendes Repository aktualisieren",
"fetch": "Remote-Änderungen herunterladen",
"checkout": "Branch wechseln"
"checkout": "Branch wechseln",
"checkoutTag": "Tag auschecken"
},
"config": {
"link": "Mercurial",

View File

@@ -5,7 +5,8 @@
"create" : "Create a new repository",
"replace" : "Push an existing repository",
"fetch": "Get remote changes",
"checkout": "Switch branch"
"checkout": "Switch branch",
"checkoutTag": "Checkout tag"
},
"config": {
"link": "Mercurial",

View File

@@ -23,6 +23,7 @@
*/
import queryString from "query-string";
import { RouteComponentProps } from "react-router-dom";
//@ts-ignore
export const contextPath = window.ctxPath || "";
@@ -80,3 +81,19 @@ function parsePageNumber(pageAsString: string) {
export function getQueryStringFromLocation(location: any) {
return location.search ? queryString.parse(location.search).q : undefined;
}
export function stripEndingSlash(url: string) {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
}
export function matchedUrlFromMatch(match: any) {
return stripEndingSlash(match.url);
}
export function matchedUrl(props: RouteComponentProps) {
const match = props.match;
return matchedUrlFromMatch(match);
}

View File

@@ -58,6 +58,6 @@ export type NamespaceCollection = {
export type RepositoryGroup = {
name: string;
namespace: Namespace;
namespace?: Namespace;
repositories: Repository[];
};

View File

@@ -27,5 +27,6 @@ import { Links } from "./hal";
export type Tag = {
name: string;
revision: string;
date?: Date;
_links: Links;
};

View File

@@ -5,5 +5,10 @@
"settingsNavLink": "Einstellungen",
"permissionsNavLink": "Berechtigungen"
}
},
"repositoryOverview": {
"settings": {
"tooltip": "Einstellungen für den Namespace"
}
}
}

View File

@@ -31,6 +31,7 @@
"navigationLabel": "Repository",
"informationNavLink": "Informationen",
"branchesNavLink": "Branches",
"tagsNavLink": "Tags",
"sourcesNavLink": "Code",
"settingsNavLink": "Einstellungen",
"generalNavLink": "Generell",
@@ -69,6 +70,21 @@
"sources": "Sources",
"defaultTag": "Default"
},
"tags": {
"overview": {
"title": "Übersicht aller verfügbaren Tags",
"noTags": "Keine Tags gefunden.",
"created": "Erstellt"
},
"table": {
"tags": "Tags"
}
},
"tag": {
"name": "Name",
"commit": "Commit",
"sources": "Sources"
},
"code": {
"sources": "Sources",
"commits": "Commits",

View File

@@ -5,5 +5,10 @@
"settingsNavLink": "Settings",
"permissionsNavLink": "Permissions"
}
},
"repositoryOverview": {
"settings": {
"tooltip": "Namespace related settings"
}
}
}

View File

@@ -31,6 +31,7 @@
"navigationLabel": "Repository",
"informationNavLink": "Information",
"branchesNavLink": "Branches",
"tagsNavLink": "Tags",
"sourcesNavLink": "Code",
"settingsNavLink": "Settings",
"generalNavLink": "General",
@@ -69,6 +70,21 @@
"sources": "Sources",
"defaultTag": "Default"
},
"tags": {
"overview": {
"title": "Overview of all tags",
"noTags": "No tags found.",
"created": "Created"
},
"table": {
"tags": "Tags"
}
},
"tag": {
"name": "Name",
"commit": "Commit",
"sources": "Sources"
},
"code": {
"sources": "Sources",
"commits": "Commits",

View File

@@ -45,6 +45,7 @@ import GlobalConfig from "./GlobalConfig";
import RepositoryRoles from "../roles/containers/RepositoryRoles";
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole";
import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -54,30 +55,17 @@ type Props = RouteComponentProps &
};
class Admin extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
if (url.includes("role")) {
return url.substring(0, url.length - 2);
}
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
matchesRoles = (route: any) => {
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const regex = new RegExp(`${url}/role/`);
return route.location.pathname.match(regex);
};
render() {
const { links, availablePluginsLink, installedPluginsLink, t } = this.props;
const { links, availablePluginsLink, installedPluginsLink, match, t } = this.props;
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
links,
url

View File

@@ -23,7 +23,7 @@
*/
import React from "react";
import { connect } from "react-redux";
import { Route, withRouter } from "react-router-dom";
import { Route, RouteComponentProps, withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
@@ -34,39 +34,29 @@ import { fetchRoleByName, getFetchRoleFailure, getRoleByName, isFetchRolePending
import PermissionRoleDetail from "../components/PermissionRoleDetails";
import EditRepositoryRole from "./EditRepositoryRole";
import { compose } from "redux";
import { urls } from "@scm-manager/ui-components";
type Props = WithTranslation & {
roleName: string;
role: RepositoryRole;
loading: boolean;
error: Error;
repositoryRolesLink: string;
disabled: boolean;
type Props = WithTranslation &
RouteComponentProps & {
roleName: string;
role: RepositoryRole;
loading: boolean;
error: Error;
repositoryRolesLink: string;
disabled: boolean;
// dispatcher function
fetchRoleByName: (p1: string, p2: string) => void;
// dispatcher function
fetchRoleByName: (p1: string, p2: string) => void;
// context objects
match: any;
history: History;
};
// context objects
history: History;
};
class SingleRepositoryRole extends React.Component<Props> {
componentDidMount() {
this.props.fetchRoleByName(this.props.repositoryRolesLink, this.props.roleName);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t, loading, error, role } = this.props;
@@ -80,7 +70,7 @@ class SingleRepositoryRole extends React.Component<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
role,

View File

@@ -44,6 +44,7 @@ import ProfileInfo from "./ProfileInfo";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -54,17 +55,6 @@ type Props = RouteComponentProps &
};
class Profile extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
mayChangePassword = () => {
const { me } = this.props;
return !!me?._links?.password;
@@ -76,7 +66,7 @@ class Profile extends React.Component<Props> {
};
render() {
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const { me, t } = this.props;

View File

@@ -45,6 +45,7 @@ import { Details } from "./../components/table";
import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks";
import EditGroup from "./EditGroup";
import SetPermissions from "../../permissions/components/SetPermissions";
import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -63,17 +64,6 @@ class SingleGroup extends React.Component<Props> {
this.props.fetchGroupByName(this.props.groupLink, this.props.name);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t, loading, error, group } = this.props;
@@ -85,7 +75,7 @@ class SingleGroup extends React.Component<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
group,

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import { Link } from "react-router-dom";
import { Branch } from "@scm-manager/ui-types";
import DefaultBranchTag from "./DefaultBranchTag";
@@ -31,24 +31,18 @@ type Props = {
branch: Branch;
};
class BranchRow extends React.Component<Props> {
renderLink(to: string, label: string, defaultBranch?: boolean) {
return (
<Link to={to} title={label}>
{label} <DefaultBranchTag defaultBranch={defaultBranch} />
</Link>
);
}
render() {
const { baseUrl, branch } = this.props;
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
return (
<tr>
<td>{this.renderLink(to, branch.name, branch.defaultBranch)}</td>
</tr>
);
}
}
const BranchRow: FC<Props> = ({ baseUrl, branch }) => {
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
return (
<tr>
<td>
<Link to={to} title={branch.name}>
{branch.name}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</Link>
</td>
</tr>
);
};
export default BranchRow;

View File

@@ -31,6 +31,7 @@ import { fetchBranch, getBranch, getFetchBranchFailure, isFetchBranchPending } f
import { ErrorNotification, Loading, NotFoundError } from "@scm-manager/ui-components";
import { History } from "history";
import queryString from "query-string";
import { urls } from "@scm-manager/ui-components";
type Props = {
repository: Repository;
@@ -54,21 +55,10 @@ class BranchRoot extends React.Component<Props> {
fetchBranch(repository, branchName);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { repository, branch, loading, error, match, location } = this.props;
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
if (error) {
if (error instanceof NotFoundError && queryString.parse(location.search).create === "true") {

View File

@@ -24,7 +24,7 @@
import { FAILURE_SUFFIX, PENDING_SUFFIX, RESET_SUFFIX, SUCCESS_SUFFIX } from "../../../modules/types";
import { apiClient } from "@scm-manager/ui-components";
import { Action, Branch, BranchRequest, Repository } from "@scm-manager/ui-types";
import { Action, Branch, BranchRequest, Repository, Link } from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
@@ -65,7 +65,7 @@ export function fetchBranches(repository: Repository) {
return function(dispatch: any) {
dispatch(fetchBranchesPending(repository));
return apiClient
.get(repository._links.branches.href)
.get((repository._links.branches as Link).href)
.then(response => response.json())
.then(data => {
dispatch(fetchBranchesSuccess(data, repository));
@@ -77,7 +77,7 @@ export function fetchBranches(repository: Repository) {
}
export function fetchBranch(repository: Repository, name: string) {
let link = repository._links.branches.href;
let link = (repository._links.branches as Link).href;
if (!link.endsWith("/")) {
link += "/";
}

View File

@@ -26,17 +26,18 @@ import { Link } from "react-router-dom";
import { CardColumnGroup, RepositoryEntry } from "@scm-manager/ui-components";
import { RepositoryGroup } from "@scm-manager/ui-types";
import { Icon } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
type Props = {
type Props = WithTranslation & {
group: RepositoryGroup;
};
class RepositoryGroupEntry extends React.Component<Props> {
render() {
const { group } = this.props;
const { group, t } = this.props;
const settingsLink = group.namespace?._links?.permissions && (
<Link to={`/namespace/${group.name}/settings`}>
<Icon color={"is-link"} name={"cog"} />
<Icon color={"is-link"} name={"cog"} title={t("repositoryOverview.settings.tooltip")} />
</Link>
);
const namespaceHeader = (
@@ -54,4 +55,4 @@ class RepositoryGroupEntry extends React.Component<Props> {
}
}
export default RepositoryGroupEntry;
export default withTranslation("namespaces")(RepositoryGroupEntry);

View File

@@ -65,5 +65,5 @@ function sortByName(a, b) {
}
function findNamespace(namespaces: NamespaceCollection, namespaceToFind: string) {
return namespaces._embedded.namespaces.filter(namespace => namespace.namespace === namespaceToFind)[0];
return namespaces._embedded.namespaces.find(namespace => namespace.namespace === namespaceToFind);
}

View File

@@ -28,6 +28,7 @@ import { Repository, Branch } from "@scm-manager/ui-types";
import Changesets from "./Changesets";
import { compose } from "redux";
import CodeActionBar from "../codeSection/components/CodeActionBar";
import { urls } from "@scm-manager/ui-components";
type Props = WithTranslation &
RouteComponentProps & {
@@ -38,13 +39,6 @@ type Props = WithTranslation &
};
class ChangesetsRoot extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
isBranchAvailable = () => {
const { branches, selectedBranch } = this.props;
return branches?.filter(b => b.name === selectedBranch).length === 0;
@@ -75,7 +69,7 @@ class ChangesetsRoot extends React.Component<Props> {
return null;
}
const url = this.stripEndingSlash(match.url);
const url = urls.stripEndingSlash(match.url);
return (
<>

View File

@@ -23,7 +23,7 @@
*/
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import { RouteComponentProps, withRouter } from "react-router-dom";
import RepositoryForm from "../components/form";
import { Repository, Links } from "@scm-manager/ui-types";
import { getModifyRepoFailure, isModifyRepoPending, modifyRepo, modifyRepoReset } from "../modules/repos";
@@ -33,8 +33,9 @@ import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { compose } from "redux";
import DangerZone from "./DangerZone";
import { getLinks } from "../../modules/indexResource";
import { urls } from "@scm-manager/ui-components";
type Props = {
type Props = RouteComponentProps & {
loading: boolean;
error: Error;
indexLinks: Links;
@@ -45,7 +46,6 @@ type Props = {
// context props
repository: Repository;
history: History;
match: any;
};
class EditRepo extends React.Component<Props> {
@@ -59,21 +59,10 @@ class EditRepo extends React.Component<Props> {
history.push(`/repo/${repository.namespace}/${repository.name}`);
};
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { loading, error, repository, indexLinks } = this.props;
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
repository,

View File

@@ -54,6 +54,9 @@ import CodeOverview from "../codeSection/containers/CodeOverview";
import ChangesetView from "./ChangesetView";
import SourceExtensions from "../sources/containers/SourceExtensions";
import { FileControlFactory, JumpToFileButton } from "@scm-manager/ui-components";
import TagsOverview from "../tags/container/TagsOverview";
import TagRoot from "../tags/container/TagRoot";
import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -82,25 +85,20 @@ class RepositoryRoot extends React.Component<Props> {
}
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
matchesBranches = (route: any) => {
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const regex = new RegExp(`${url}/branch/.+/info`);
return route.location.pathname.match(regex);
};
matchesTags = (route: any) => {
const url = urls.matchedUrl(this.props);
const regex = new RegExp(`${url}/tag/.+/info`);
return route.location.pathname.match(regex);
};
matchesCode = (route: any) => {
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const regex = new RegExp(`${url}(/code)/.*`);
return route.location.pathname.match(regex);
};
@@ -118,7 +116,7 @@ class RepositoryRoot extends React.Component<Props> {
evaluateDestinationForCodeLink = () => {
const { repository } = this.props;
const url = `${this.matchedUrl()}/code`;
const url = `${urls.matchedUrl(this.props)}/code`;
if (repository?._links?.sources) {
return `${url}/sources/`;
}
@@ -138,7 +136,7 @@ class RepositoryRoot extends React.Component<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
repository,
@@ -245,6 +243,15 @@ class RepositoryRoot extends React.Component<Props> {
render={() => <BranchesOverview repository={repository} baseUrl={`${url}/branch`} />}
/>
<Route path={`${url}/branches/create`} render={() => <CreateBranch repository={repository} />} />
<Route
path={`${url}/tag/:tag`}
render={() => <TagRoot repository={repository} baseUrl={`${url}/tag`} />}
/>
<Route
path={`${url}/tags`}
exact={true}
render={() => <TagsOverview repository={repository} baseUrl={`${url}/tag`} />}
/>
<ExtensionPoint name="repository.route" props={extensionProps} renderAll={true} />
</Switch>
</PrimaryContentColumn>
@@ -267,6 +274,16 @@ class RepositoryRoot extends React.Component<Props> {
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.branchesNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName="tags"
to={`${url}/tags/`}
icon="fas fa-tags"
label={t("repositoryRoot.menu.tagsNavLink")}
activeWhenMatch={this.matchesTags}
activeOnlyWhenExact={false}
title={t("repositoryRoot.menu.tagsNavLink")}
/>
<RepositoryNavLink
repository={repository}
linkName={this.getCodeLinkname()}

View File

@@ -33,7 +33,8 @@ import {
CustomQueryFlexWrappedColumns,
ErrorPage,
Loading,
Page, PrimaryContentColumn,
Page,
PrimaryContentColumn,
SecondaryNavigation,
SecondaryNavigationColumn,
StateMenuContextProvider,
@@ -42,6 +43,7 @@ import {
import Permissions from "../../permissions/containers/Permissions";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import PermissionsNavLink from "./PermissionsNavLink";
import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -49,6 +51,7 @@ type Props = RouteComponentProps &
namespaceName: string;
namespacesLink: string;
namespace: Namespace;
error: Error;
// dispatch functions
fetchNamespace: (link: string, namespace: string) => void;
@@ -60,20 +63,9 @@ class NamespaceRoot extends React.Component<Props> {
fetchNamespace(namespacesLink, namespaceName);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { loading, error, namespaceName, namespace, t } = this.props;
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
namespace,

View File

@@ -0,0 +1,53 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Tag, Repository } from "@scm-manager/ui-types";
import { Button, ButtonAddons } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
repository: Repository;
tag: Tag;
};
const TagButtonGroup: FC<Props> = ({ repository, tag }) => {
const [t] = useTranslation("repos");
const changesetLink = `/repo/${repository.namespace}/${repository.name}/code/changeset/${encodeURIComponent(
tag.revision
)}`;
const sourcesLink = `/repo/${repository.namespace}/${repository.name}/sources/${encodeURIComponent(tag.revision)}/`;
return (
<>
<ButtonAddons>
<Button link={changesetLink} icon="exchange-alt" label={t("tag.commit")} reducedMobile={true} />
<Button link={sourcesLink} icon="code" label={t("tag.sources")} reducedMobile={true} />
</ButtonAddons>
</>
);
};
export default TagButtonGroup;

View File

@@ -0,0 +1,73 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Repository, Tag } from "@scm-manager/ui-types";
import { DateFromNow, Level } from "@scm-manager/ui-components";
import styled from "styled-components";
import TagButtonGroup from "./TagButtonGroup";
type Props = {
repository: Repository;
tag: Tag;
};
const FlexRow = styled.div`
display: flex;
align-items: center;
`;
const Created = styled.div`
margin-left: 0.5rem;
font-size: 0.8rem;
`;
const Label = styled.strong`
margin-right: 0.3rem;
`;
const Date = styled(DateFromNow)`
font-size: 0.8rem;
`;
const TagDetail: FC<Props> = ({ tag, repository }) => {
const [t] = useTranslation("repos");
return (
<div className="media">
<FlexRow className="media-content subtitle">
<Label>{t("tag.name") + ": "} </Label> {tag.name}
<Created className="is-ellipsis-overflow">
{t("tags.overview.created")} <Date date={tag.date} className="has-text-grey" />
</Created>
</FlexRow>
<div className="media-right">
<TagButtonGroup repository={repository} tag={tag} />
</div>
</div>
);
};
export default TagDetail;

View File

@@ -0,0 +1,60 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Tag } from "@scm-manager/ui-types";
import styled from "styled-components";
import { DateFromNow } from "@scm-manager/ui-components";
type Props = {
tag: Tag;
baseUrl: string;
};
const Created = styled.span`
margin-left: 1rem;
font-size: 0.8rem;
`;
const TagRow: FC<Props> = ({ tag, baseUrl }) => {
const [t] = useTranslation("repos");
const to = `${baseUrl}/${encodeURIComponent(tag.name)}/info`;
return (
<tr>
<td>
<Link to={to} title={tag.name}>
{tag.name}
<Created className="has-text-grey is-ellipsis-overflow">
{t("tags.overview.created")} <DateFromNow date={tag.date} />
</Created>
</Link>
</td>
</tr>
);
};
export default TagRow;

View File

@@ -0,0 +1,60 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Tag } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
import TagRow from "./TagRow";
type Props = {
baseUrl: string;
tags: Tag[];
};
const TagTable: FC<Props> = ({ baseUrl, tags }) => {
const [t] = useTranslation("repos");
const renderRow = () => {
let rowContent = null;
if (tags) {
rowContent = tags.map((tag, index) => {
return <TagRow key={index} baseUrl={baseUrl} tag={tag} />;
});
}
return rowContent;
};
return (
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
<th>{t("tags.table.tags")}</th>
</tr>
</thead>
<tbody>{renderRow()}</tbody>
</table>
);
};
export default TagTable;

View File

@@ -0,0 +1,54 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Repository, Tag } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import TagDetail from "./TagDetail";
type Props = {
repository: Repository;
tag: Tag;
};
const TagView: FC<Props> = ({ repository, tag }) => {
return (
<>
<TagDetail tag={tag} repository={repository} />
<hr />
<div className="content">
<ExtensionPoint
name="repos.tag-details.information"
renderAll={true}
props={{
repository,
tag
}}
/>
</div>
</>
);
};
export default TagView;

View File

@@ -0,0 +1,87 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Link, Repository, Tag } from "@scm-manager/ui-types";
import { Redirect, Switch, useLocation, useRouteMatch, Route } from "react-router-dom";
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import TagView from "../components/TagView";
import { urls } from "@scm-manager/ui-components";
type Props = {
repository: Repository;
baseUrl: string;
};
const TagRoot: FC<Props> = ({ repository, baseUrl }) => {
const match = useRouteMatch();
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [tag, setTag] = useState<Tag>();
useEffect(() => {
const link = (repository._links?.tags as Link)?.href;
if (link) {
apiClient
.get(link)
.then(r => r.json())
.then(r => setTags(r._embedded.tags))
.catch(setError);
}
}, [repository]);
useEffect(() => {
const tagName = decodeURIComponent(match?.params?.tag);
const link = tags?.length > 0 && (tags.find(tag => tag.name === tagName)?._links.self as Link).href;
if (link) {
apiClient
.get(link)
.then(r => r.json())
.then(setTag)
.then(() => setLoading(false))
.catch(setError);
}
}, [tags]);
if (error) {
return <ErrorNotification error={error} />;
}
if (loading || !tags) {
return <Loading />;
}
const url = urls.matchedUrlFromMatch(match);
return (
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route path={`${url}/info`} component={() => <TagView repository={repository} tag={tag} />} />
</Switch>
);
};
export default TagRoot;

View File

@@ -0,0 +1,75 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Repository, Tag, Link } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Notification, Subtitle, apiClient } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import orderTags from "../orderTags";
import TagTable from "../components/TagTable";
type Props = {
repository: Repository;
baseUrl: string;
};
const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
const [t] = useTranslation("repos");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
const link = (repository._links?.tags as Link)?.href;
if (link) {
setLoading(true);
apiClient
.get(link)
.then(r => r.json())
.then(r => setTags(r._embedded.tags))
.then(() => setLoading(false))
.catch(setError);
}
}, [repository]);
const renderTagsTable = () => {
if (!loading && tags?.length > 0) {
orderTags(tags);
return <TagTable baseUrl={baseUrl} tags={tags} />;
}
return <Notification type="info">{t("tags.overview.noTags")}</Notification>;
};
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return <>{renderTagsTable()}</>;
};
export default TagsOverview;

View File

@@ -0,0 +1,52 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import orderTags from "./orderTags";
const tag1 = {
name: "tag1",
revision: "revision1",
date: new Date(2020, 1, 1),
_links: {}
};
const tag2 = {
name: "tag2",
revision: "revision2",
date: new Date(2020, 1, 3),
_links: {}
};
const tag3 = {
name: "tag3",
revision: "revision3",
date: new Date(2020, 1, 2),
_links: {}
};
describe("order tags", () => {
it("should order tags descending by date", () => {
const tags = [tag1, tag2, tag3];
orderTags(tags);
expect(tags).toEqual([tag2, tag3, tag1]);
});
});

View File

@@ -0,0 +1,32 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// sort tags by date beginning with latest first
import { Tag } from "@scm-manager/ui-types";
export default (tags: Tag[]) => {
tags.sort((a, b) => {
return new Date(b.date) - new Date(a.date);
});
};

View File

@@ -47,6 +47,7 @@ import { mustGetUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -65,17 +66,6 @@ class SingleUser extends React.Component<Props> {
this.props.fetchUserByName(this.props.usersLink, this.props.name);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t, loading, error, user } = this.props;
@@ -87,7 +77,7 @@ class SingleUser extends React.Component<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
user,

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
@@ -31,17 +31,23 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
@Getter @Setter @NoArgsConstructor
@Getter
@Setter
@NoArgsConstructor
@SuppressWarnings("java:S2160") // we do not need this for dto
public class BranchDto extends HalRepresentation {
private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>";
private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/.";
static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?";
@NotEmpty @Length(min = 1, max=100) @Pattern(regexp = VALID_BRANCH_NAMES)
@NotEmpty
@Length(min = 1, max = 100)
@Pattern(regexp = VALID_BRANCH_NAMES)
private String name;
private String revision;
private boolean defaultBranch;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.google.common.base.Strings;
@@ -120,7 +120,11 @@ public class BranchRootResource {
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("branch") String branchName) throws IOException {
public Response get(
@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("branch") String branchName
) throws IOException {
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
Branches branches = repositoryService.getBranchesCommand().getBranches();
@@ -293,7 +297,10 @@ public class BranchRootResource {
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException {
public Response getAll(
@PathParam("namespace") String namespace,
@PathParam("name") String name
) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Branches branches = repositoryService.getBranchesCommand().getBranches();
return Response.ok(branchCollectionToDtoMapper.map(repositoryService.getRepository(), branches.getBranches())).build();

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;

View File

@@ -60,10 +60,10 @@ import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX;
@Slf4j
public class NamespacePermissionResource {
private RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper;
private RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper;
private RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper;
private ResourceLinks resourceLinks;
private final RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper;
private final RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper;
private final RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper;
private final ResourceLinks resourceLinks;
private final NamespaceManager manager;
@Inject
@@ -234,7 +234,6 @@ public class NamespacePermissionResource {
public void update(@PathParam("namespace") String namespaceName,
@PathParam("permission-name") String permissionName,
@Valid RepositoryPermissionDto permission) {
log.info("try to update the permission with name: {}. the modified permission is: {}", permissionName, permission);
Namespace namespace = load(namespaceName);
String extractedPermissionName = getPermissionName(permissionName);
if (!isPermissionExist(new RepositoryPermissionDto(extractedPermissionName, isGroupPermission(permissionName)), namespace)) {
@@ -256,7 +255,7 @@ public class NamespacePermissionResource {
}
namespace.addPermission(newPermission);
manager.modify(namespace);
log.info("the permission with name: {} is updated.", permissionName);
log.info("the permission with name: {} is updated to {}.", permissionName, permission);
}
/**
@@ -295,9 +294,9 @@ public class NamespacePermissionResource {
}
private Predicate<RepositoryPermission> filterPermission(String name) {
return permission -> getPermissionName(name).equals(permission.getName())
&&
permission.isGroupPermission() == isGroupPermission(name);
return permission ->
getPermissionName(name).equals(permission.getName())
&& permission.isGroupPermission() == isGroupPermission(name);
}
private String getPermissionName(String permissionName) {

View File

@@ -56,16 +56,6 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper {
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Namespace namespace);
@BeforeMapping
void validatePermissions(@Context Repository repository) {
RepositoryPermissions.permissionRead(repository).check();
}
@BeforeMapping
void validatePermissions(@Context Namespace namespace) {
NamespacePermissions.permissionRead().check();
}
@AfterMapping
void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Repository repository) {
String permissionName = getUrlPermissionName(target);

View File

@@ -80,8 +80,7 @@ public class DefaultNamespaceManager implements NamespaceManager {
@Subscribe
public void cleanupDeletedNamespaces(RepositoryEvent repositoryEvent) {
HandlerEventType eventType = repositoryEvent.getEventType();
if (eventType == HandlerEventType.DELETE || eventType == HandlerEventType.MODIFY && !repositoryEvent.getItem().getNamespace().equals(repositoryEvent.getOldItem().getNamespace())) {
if (namespaceRelevantChange(repositoryEvent)) {
Collection<String> allNamespaces = repositoryManager.getAllNamespaces();
String oldNamespace = getOldNamespace(repositoryEvent);
if (!allNamespaces.contains(oldNamespace)) {
@@ -90,6 +89,12 @@ public class DefaultNamespaceManager implements NamespaceManager {
}
}
public boolean namespaceRelevantChange(RepositoryEvent repositoryEvent) {
HandlerEventType eventType = repositoryEvent.getEventType();
return eventType == HandlerEventType.DELETE
|| eventType == HandlerEventType.MODIFY && !repositoryEvent.getItem().getNamespace().equals(repositoryEvent.getOldItem().getNamespace());
}
public String getOldNamespace(RepositoryEvent repositoryEvent) {
if (repositoryEvent.getEventType() == HandlerEventType.DELETE) {
return repositoryEvent.getItem().getNamespace();

View File

@@ -162,7 +162,12 @@ public class AuthorizationChangedEventProducer {
Repository repository = event.getItem();
if (isAuthorizationDataModified(repository.getPermissions(), event.getItemBeforeModification().getPermissions())) {
logger.debug(
"fire authorization changed event, because a relevant field of repository {}/{} has changed", repository.getNamespace(), repository.getName()
"fire authorization changed event, because the permissions of repository {}/{} have changed", repository.getNamespace(), repository.getName()
);
fireEventForEveryUser();
} else if (!event.getItem().getNamespace().equals(event.getItemBeforeModification().getNamespace())) {
logger.debug(
"fire authorization changed event, because the namespace of repository {}/{} has changed", repository.getNamespace(), repository.getName()
);
fireEventForEveryUser();
} else {

View File

@@ -95,11 +95,11 @@
},
"namespace": {
"permissionRead": {
"displayName": "read permissions on namespaces",
"displayName": "Read permissions on namespaces",
"description": "May see the permissions set for namespaces"
},
"permissionWrite": {
"displayName": "modify permissions on namespaces",
"displayName": "Modify permissions on namespaces",
"description": "May modify the permissions set for namespaces"
}
},

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import org.junit.jupiter.api.Test;
@@ -62,5 +62,4 @@ class BranchToBranchDtoMapperTest {
BranchDto dto = mapper.map(branch, new NamespaceAndName("hitchhiker", "heart-of-gold"));
assertThat(dto.getLinks().getLinkBy("ka").get().getHref()).isEqualTo("http://hitchhiker/heart-of-gold/master");
}
}

View File

@@ -224,26 +224,6 @@ class NamespaceRootResourceTest {
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
void shouldNotCreateNewPermission() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions")
.content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes())
.header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void shouldNotDeletePermission() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(403);
}
@Nested
class WithWritePermission {

View File

@@ -24,8 +24,13 @@
package sonia.scm.repository;
import com.github.legman.EventBus;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@@ -41,6 +46,7 @@ import java.util.Optional;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static sonia.scm.HandlerEventType.DELETE;
@@ -54,6 +60,8 @@ class DefaultNamespaceManagerTest {
RepositoryManager repositoryManager;
@Mock
ScmEventBus eventBus;
@Mock
Subject subject;
Namespace life;
@@ -64,8 +72,7 @@ class DefaultNamespaceManagerTest {
@BeforeEach
void mockExistingNamespaces() {
dao = new NamespaceDao(new InMemoryDataStoreFactory(new InMemoryDataStore()));
manager = new DefaultNamespaceManager(repositoryManager, dao, eventBus);
dao = new NamespaceDao(new InMemoryDataStoreFactory(new InMemoryDataStore<Namespace>()));
when(repositoryManager.getAllNamespaces()).thenReturn(asList("life", "universe", "rest"));
@@ -76,6 +83,18 @@ class DefaultNamespaceManagerTest {
universe = new Namespace("universe");
rest = new Namespace("rest");
manager = new DefaultNamespaceManager(repositoryManager, dao, eventBus);
}
@BeforeEach
void mockSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void unbindSubject() {
ThreadContext.unbindSubject();
}
@Test
@@ -85,49 +104,6 @@ class DefaultNamespaceManagerTest {
assertThat(namespace).isEmpty();
}
@Test
void shouldCreateNewNamespaceObjectIfNotInStore() {
Namespace namespace = manager.get("universe").orElse(null);
assertThat(namespace).isEqualTo(universe);
assertThat(namespace.getPermissions()).isEmpty();
}
@Test
void shouldEnrichExistingNamespaceWithPermissions() {
Namespace namespace = manager.get("life").orElse(null);
assertThat(namespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0]));
}
@Test
void shouldEnrichExistingNamespaceWithPermissionsInGetAll() {
Collection<Namespace> namespaces = manager.getAll();
assertThat(namespaces).containsExactly(
life,
universe,
rest
);
Namespace foundLifeNamespace = namespaces.stream().filter(namespace -> namespace.getNamespace().equals("life")).findFirst().get();
assertThat(
foundLifeNamespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0]));
}
@Test
void shouldModifyExistingNamespaceWithPermissions() {
Namespace modifiedNamespace = manager.get("life").get();
modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false)));
manager.modify(modifiedNamespace);
Namespace newLife = manager.get("life").get();
assertThat(newLife).isEqualTo(modifiedNamespace);
verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent)event).getEventType() == HandlerEventType.BEFORE_MODIFY));
verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent)event).getEventType() == HandlerEventType.MODIFY));
}
@Test
void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasDeleted() {
when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest"));
@@ -149,4 +125,93 @@ class DefaultNamespaceManagerTest {
assertThat(dao.get("life")).isEmpty();
}
@Nested
class WithPermissionToReadPermissions {
@BeforeEach
void grantReadPermission() {
when(subject.isPermitted("namespace:permissionRead")).thenReturn(true);
}
@Test
void shouldCreateNewNamespaceObjectIfNotInStore() {
Namespace namespace = manager.get("universe").orElse(null);
assertThat(namespace).isEqualTo(universe);
assertThat(namespace.getPermissions()).isEmpty();
}
@Test
void shouldEnrichExistingNamespaceWithPermissions() {
Namespace namespace = manager.get("life").orElse(null);
assertThat(namespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0]));
}
@Test
void shouldEnrichExistingNamespaceWithPermissionsInGetAll() {
Collection<Namespace> namespaces = manager.getAll();
assertThat(namespaces).containsExactly(
life,
universe,
rest
);
Namespace foundLifeNamespace = namespaces.stream().filter(namespace -> namespace.getNamespace().equals("life")).findFirst().get();
assertThat(
foundLifeNamespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0]));
}
@Test
void shouldModifyExistingNamespaceWithPermissions() {
Namespace modifiedNamespace = manager.get("life").get();
modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false)));
manager.modify(modifiedNamespace);
Namespace newLife = manager.get("life").get();
assertThat(newLife).isEqualTo(modifiedNamespace);
verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent) event).getEventType() == HandlerEventType.BEFORE_MODIFY));
verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent) event).getEventType() == HandlerEventType.MODIFY));
}
}
@Nested
class WithoutPermissionToReadOrWritePermissions {
@BeforeEach
void grantReadPermission() {
when(subject.isPermitted("namespace:permissionRead")).thenReturn(false);
lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite");
}
@Test
void shouldNotEnrichExistingNamespaceWithPermissions() {
Namespace namespace = manager.get("life").orElse(null);
assertThat(namespace.getPermissions()).isEmpty();
}
@Test
void shouldNotEnrichExistingNamespaceWithPermissionsInGetAll() {
Collection<Namespace> namespaces = manager.getAll();
assertThat(namespaces).containsExactly(
new Namespace("life"),
universe,
rest
);
}
@Test
void shouldNotModifyExistingNamespaceWithPermissions() {
Namespace modifiedNamespace = manager.get("life").get();
modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false)));
Assertions.assertThrows(AuthorizationException.class, () -> manager.modify(modifiedNamespace));
}
}
}

View File

@@ -220,6 +220,18 @@ public class AuthorizationChangedEventProducerTest {
assertEventIsNotFired();
}
@Test
public void testOnRepositoryNamespaceChanged()
{
Repository repositoryModified = RepositoryTestData.createHeartOfGold();
repositoryModified.setName("test123");
Repository repository = RepositoryTestData.createHeartOfGold();
repositoryModified.setNamespace("new_namespace");
producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository));
assertGlobalEventIsFired();
}
private void resetStoredEvent(){
producer.event = null;
}