Merge pull request #1422 from scm-manager/feature/delete_branches

Feature/delete branches
This commit is contained in:
Eduard Heimbuch
2020-11-12 17:05:20 +01:00
committed by GitHub
30 changed files with 485 additions and 76 deletions

View File

@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Delete branches directly in the UI ([#1422](https://github.com/scm-manager/scm-manager/pull/1422))
- Lookup command which provides further repository information ([#1415](https://github.com/scm-manager/scm-manager/pull/1415))
- Include messages from scm protocol in modification or merge errors ([#1420](https://github.com/scm-manager/scm-manager/pull/1420))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -6,6 +6,7 @@ subtitle: Branches
Auf der Branches-Übersicht sind die bereits existierenden Branches aufgeführt. Bei einem Klick auf einen Branch wird man zur Detailseite des Branches weitergeleitet.
Der Tag "Default" gibt an welcher Branch aktuell, als Standard-Branch dieses Repository im SCM-Manager markiert ist. Der Standard-Branch wird immer zuerst angezeigt, wenn man das Repository im SCM-Manager öffnet.
Alle Branches mit Ausnahme des Default Branches können über den Mülleimer-Icon unwiderruflich gelöscht werden.
Über den "Branch erstellen"-Button gelangt man zum Formular, um neue Branches anzulegen.
@@ -19,4 +20,6 @@ Mit dem "Branch erstellen"-Formular können neue Branches für das Repository er
### Branch Detailseite
Hier werden einige Befehle zum Arbeiten mit dem Branch auf einer Kommandozeile aufgeführt.
Handelt es sich nicht um den Default Branch des Repositories, kann im unteren Bereich der Branch unwiderruflich gelöscht werden.
![Branch Detailseite](assets/repository-branch-detailView.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -6,6 +6,7 @@ subtitle: Branches
The branches overview shows the branches that are already existing. By clicking on a branch, the details page of the branch is shown.
The tag "Default" shows which branch is currently set as the default branch of the repository in SCM-Manager. The default branch is always shown first when opening the repository in SCM-Manager.
All branches except the default branch of the repository can be deleted by clicking on the trash bin icon.
The button "Create Branch" opens the form to create a new branch.
@@ -18,5 +19,6 @@ New branches can be created with the "Create Branch" form. There, you have to ch
### Branch Details Page
This page shows some commands to work with the branch on the command line.
If the branch is not the default branch of the repository it can be deleted using the action inside the bottom section.
![Branch Details Page](assets/repository-branch-detailView.png)

View File

@@ -62,11 +62,11 @@ export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
const footer = (
<div className="field is-grouped">
{buttons.map((button, i) => (
<p className="control">
{buttons.map((button, index) => (
<p className="control" key={index}>
<a
className={classNames("button", "is-info", button.className)}
key={i}
key={index}
onClick={() => handleClickButton(button)}
>
{button.label}

View File

@@ -68,7 +68,19 @@
"name": "Name:",
"commits": "Commits",
"sources": "Sources",
"defaultTag": "Default"
"defaultTag": "Default",
"dangerZone": "Branch löschen",
"delete": {
"button": "Branch löschen",
"subtitle": "Branch löschen",
"description": "Gelöschte Branches können nicht wiederhergestellt werden.",
"confirmAlert": {
"title": "Branch löschen",
"message": "Möchten Sie den Branch \"{{branch}}\" wirklich löschen?",
"cancel": "Nein",
"submit": "Ja"
}
}
},
"tags": {
"overview": {

View File

@@ -68,7 +68,19 @@
"name": "Name:",
"commits": "Commits",
"sources": "Sources",
"defaultTag": "Default"
"defaultTag": "Default",
"dangerZone": "Delete branch",
"delete": {
"button": "Delete branch",
"subtitle": "Delete branch",
"description": "Deleted branches can not be restored.",
"confirmAlert": {
"title": "Delete branch",
"message": "Do you really want to delete the branch \"{{branch}}\"?",
"cancel": "No",
"submit": "Yes"
}
}
},
"tags": {
"overview": {

View File

@@ -22,25 +22,42 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Link } from "react-router-dom";
import { Branch } from "@scm-manager/ui-types";
import { Link as ReactLink } from "react-router-dom";
import { Branch, Link } from "@scm-manager/ui-types";
import DefaultBranchTag from "./DefaultBranchTag";
import { Icon } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
baseUrl: string;
branch: Branch;
onDelete: (branch: Branch) => void;
};
const BranchRow: FC<Props> = ({ baseUrl, branch }) => {
const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
const [t] = useTranslation("repos");
let deleteButton;
if ((branch?._links?.delete as Link)?.href) {
deleteButton = (
<a className="level-item" onClick={() => onDelete(branch)}>
<span className="icon is-small">
<Icon name="trash" className="fas" title={t("branch.delete.button")} />
</span>
</a>
);
}
return (
<tr>
<td>
<Link to={to} title={branch.name}>
<ReactLink to={to} title={branch.name}>
{branch.name}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</Link>
</ReactLink>
</td>
<td className="is-darker">{deleteButton}</td>
</tr>
);
};

View File

@@ -21,41 +21,84 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import BranchRow from "./BranchRow";
import { Branch } from "@scm-manager/ui-types";
import { Branch, Link } from "@scm-manager/ui-types";
import { apiClient, ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
type Props = WithTranslation & {
type Props = {
baseUrl: string;
branches: Branch[];
fetchBranches: () => void;
};
class BranchTable extends React.Component<Props> {
render() {
const { t } = this.props;
return (
const BranchTable: FC<Props> = ({ baseUrl, branches, fetchBranches }) => {
const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [branchToBeDeleted, setBranchToBeDeleted] = useState<Branch | undefined>();
const onDelete = (branch: Branch) => {
setBranchToBeDeleted(branch);
setShowConfirmAlert(true);
};
const abortDelete = () => {
setBranchToBeDeleted(undefined);
setShowConfirmAlert(false);
};
const deleteBranch = () => {
apiClient
.delete((branchToBeDeleted?._links.delete as Link).href)
.then(() => fetchBranches())
.catch(setError);
};
const renderRow = () => {
let rowContent = null;
if (branches) {
rowContent = branches.map((branch, index) => {
return <BranchRow key={index} baseUrl={baseUrl} branch={branch} onDelete={onDelete} />;
});
}
return rowContent;
};
const confirmAlert = (
<ConfirmAlert
title={t("branch.delete.confirmAlert.title")}
message={t("branch.delete.confirmAlert.message", { branch: branchToBeDeleted?.name })}
buttons={[
{
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
);
return (
<>
{showConfirmAlert && confirmAlert}
{error && <ErrorNotification error={error} />}
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
<th>{t("branches.table.branches")}</th>
</tr>
</thead>
<tbody>{this.renderRow()}</tbody>
<tbody>{renderRow()}</tbody>
</table>
);
}
</>
);
};
renderRow() {
const { baseUrl, branches } = this.props;
let rowContent = null;
if (branches) {
rowContent = branches.map((branch, index) => {
return <BranchRow key={index} baseUrl={baseUrl} branch={branch} />;
});
}
return rowContent;
}
}
export default withTranslation("repos")(BranchTable);
export default BranchTable;

View File

@@ -25,6 +25,7 @@ import React from "react";
import BranchDetail from "./BranchDetail";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Branch, Repository } from "@scm-manager/ui-types";
import BranchDangerZone from "../containers/BranchDangerZone";
type Props = {
repository: Repository;
@@ -34,7 +35,6 @@ type Props = {
class BranchView extends React.Component<Props> {
render() {
const { repository, branch } = this.props;
return (
<div>
<BranchDetail repository={repository} branch={branch} />
@@ -49,6 +49,7 @@ class BranchView extends React.Component<Props> {
}}
/>
</div>
<BranchDangerZone repository={repository} branch={branch} />
</div>
);
}

View File

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

View File

@@ -28,10 +28,9 @@ import { compose } from "redux";
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
import { Branch, Repository } from "@scm-manager/ui-types";
import { fetchBranch, getBranch, getFetchBranchFailure, isFetchBranchPending } from "../modules/branches";
import { ErrorNotification, Loading, NotFoundError } from "@scm-manager/ui-components";
import { ErrorNotification, Loading, NotFoundError, urls } 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;

View File

@@ -81,10 +81,10 @@ class BranchesOverview extends React.Component<Props> {
}
renderBranchesTable() {
const { baseUrl, branches, t } = this.props;
const { baseUrl, branches, repository, fetchBranches, t } = this.props;
if (branches && branches.length > 0) {
orderBranches(branches);
return <BranchTable baseUrl={baseUrl} branches={branches} />;
return <BranchTable baseUrl={baseUrl} branches={branches} fetchBranches={() => fetchBranches(repository)} />;
}
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}

View File

@@ -0,0 +1,92 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Branch, Link, Repository } from "@scm-manager/ui-types";
import { apiClient, ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
type Props = {
repository: Repository;
branch: Branch;
};
const DeleteBranch: FC<Props> = ({ repository, branch }: Props) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [t] = useTranslation("repos");
const history = useHistory();
const deleteBranch = () => {
apiClient
.delete((branch._links.delete as Link).href)
.then(() => history.push(`/repo/${repository.namespace}/${repository.name}/branches/`))
.catch(setError);
};
if (!branch._links.delete) {
return null;
}
let confirmAlert = null;
if (showConfirmAlert) {
confirmAlert = (
<ConfirmAlert
title={t("branch.delete.confirmAlert.title")}
message={t("branch.delete.confirmAlert.message", { branch: branch.name })}
buttons={[
{
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => null
}
]}
close={() => setShowConfirmAlert(false)}
/>
);
}
return (
<>
<ErrorNotification error={error} />
{showConfirmAlert && confirmAlert}
<Level
left={
<p>
<strong>{t("branch.delete.subtitle")}</strong>
<br />
{t("branch.delete.description")}
</p>
}
right={<DeleteButton label={t("branch.delete.button")} action={() => setShowConfirmAlert(true)} />}
/>
</>
);
};
export default DeleteBranch;

View File

@@ -31,7 +31,7 @@ import { History } from "history";
import { ErrorNotification } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { compose } from "redux";
import DangerZone from "./DangerZone";
import RepositoryDangerZone from "./RepositoryDangerZone";
import { getLinks } from "../../modules/indexResource";
import { urls } from "@scm-manager/ui-components";
@@ -80,7 +80,7 @@ class EditRepo extends React.Component<Props> {
}}
/>
<ExtensionPoint name="repo-config.route" props={extensionProps} renderAll={true} />
<DangerZone repository={repository} indexLinks={indexLinks} />
<RepositoryDangerZone repository={repository} indexLinks={indexLinks} />
</>
);
}

View File

@@ -35,7 +35,7 @@ type Props = {
indexLinks: Links;
};
const DangerZoneContainer = styled.div`
export const DangerZoneContainer = styled.div`
padding: 1.5rem 1rem;
border: 1px solid #ff6a88;
border-radius: 5px;
@@ -56,7 +56,7 @@ const DangerZoneContainer = styled.div`
}
`;
const DangerZone: FC<Props> = ({ repository, indexLinks }) => {
const RepositoryDangerZone: FC<Props> = ({ repository, indexLinks }) => {
const [t] = useTranslation("repos");
const dangerZone = [];
@@ -81,4 +81,4 @@ const DangerZone: FC<Props> = ({ repository, indexLinks }) => {
);
};
export default DangerZone;
export default RepositoryDangerZone;

View File

@@ -45,6 +45,6 @@ public class BranchChangesetCollectionToDtoMapper extends ChangesetCollectionToD
}
private String createSelfLink(Repository repository, String branch) {
return resourceLinks.branch().history(repository.getNamespaceAndName(), branch);
return resourceLinks.branch().history(repository.getNamespace(), repository.getName(), branch);
}
}

View File

@@ -30,7 +30,6 @@ import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import sonia.scm.repository.Branch;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
@@ -55,11 +54,11 @@ public class BranchCollectionToDtoMapper {
public HalRepresentation map(Repository repository, Collection<Branch> branches) {
return new HalRepresentation(
createLinks(repository),
embedDtos(getBranchDtoList(repository.getNamespace(), repository.getName(), branches)));
embedDtos(getBranchDtoList(repository, branches)));
}
public List<BranchDto> getBranchDtoList(String namespace, String name, Collection<Branch> branches) {
return branches.stream().map(branch -> branchToDtoMapper.map(branch, new NamespaceAndName(namespace, name))).collect(toList());
public List<BranchDto> getBranchDtoList(Repository repository, Collection<Branch> branches) {
return branches.stream().map(branch -> branchToDtoMapper.map(branch, repository)).collect(toList());
}
private Links createLinks(Repository repository) {

View File

@@ -47,6 +47,7 @@ import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
@@ -57,6 +58,7 @@ import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
import static sonia.scm.AlreadyExistsException.alreadyExists;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
@@ -132,7 +134,7 @@ public class BranchRootResource {
.stream()
.filter(branch -> branchName.equals(branch.getName()))
.findFirst()
.map(branch -> branchToDtoMapper.map(branch, namespaceAndName))
.map(branch -> branchToDtoMapper.map(branch, repositoryService.getRepository()))
.map(Response::ok)
.orElseThrow(() -> notFound(entity("branch", branchName).in(namespaceAndName)))
.build();
@@ -247,7 +249,7 @@ public class BranchRootResource {
branchCommand.from(parentName);
}
Branch newBranch = branchCommand.branch(branchName);
return Response.created(URI.create(resourceLinks.branch().self(namespaceAndName, newBranch.getName()))).build();
return Response.created(URI.create(resourceLinks.branch().self(namespace, name, newBranch.getName()))).build();
}
}
@@ -308,4 +310,50 @@ public class BranchRootResource {
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
/**
* Deletes a branch.
*
* <strong>Note:</strong> This method requires "repository" privilege.
*
* @param branch the name of the branch to delete.
*/
@DELETE
@Path("{branch}")
@Operation(summary = "Delete branch", description = "Deletes the given branch.", tags = "Repository")
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ApiResponse(responseCode = "400", description = "the default branch cannot be deleted")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to modify the repository")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response delete(@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("branch") String branch) {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
RepositoryPermissions.modify(repositoryService.getRepository()).check();
Optional<Branch> branchToBeDeleted = repositoryService.getBranchesCommand().getBranches().getBranches().stream()
.filter(b -> b.getName().equalsIgnoreCase(branch))
.findFirst();
if (branchToBeDeleted.isPresent()) {
if (branchToBeDeleted.get().isDefaultBranch()) {
return Response.status(400).build();
} else {
repositoryService.getBranchCommand().delete(branch);
}
}
} catch (IOException e) {
return Response.serverError().build();
}
return Response.noContent().build();
}
}

View File

@@ -32,6 +32,8 @@ import org.mapstruct.Mapping;
import org.mapstruct.ObjectFactory;
import sonia.scm.repository.Branch;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
@@ -46,16 +48,21 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper {
private ResourceLinks resourceLinks;
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
public abstract BranchDto map(Branch branch, @Context NamespaceAndName namespaceAndName);
public abstract BranchDto map(Branch branch, @Context Repository repository);
@ObjectFactory
BranchDto createDto(@Context NamespaceAndName namespaceAndName, Branch branch) {
BranchDto createDto(@Context Repository repository, Branch branch) {
NamespaceAndName namespaceAndName = new NamespaceAndName(repository.getNamespace(), repository.getName());
Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.branch().self(namespaceAndName, branch.getName()))
.single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, branch.getName())).build())
.self(resourceLinks.branch().self(repository.getNamespace(), repository.getName(), branch.getName()))
.single(linkBuilder("history", resourceLinks.branch().history(repository.getNamespace(), repository.getName(), branch.getName())).build())
.single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), branch.getRevision())).build())
.single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), branch.getRevision())).build());
if (!branch.isDefaultBranch() && RepositoryPermissions.modify(repository).isPermitted()) {
linksBuilder.single(linkBuilder("delete", resourceLinks.branch().delete(repository.getNamespace(), repository.getName(), branch.getName())).build());
}
Embedded.Builder embeddedBuilder = Embedded.embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), branch, namespaceAndName);

View File

@@ -56,7 +56,7 @@ class ChangesetCollectionToDtoMapperBase extends PagedCollectionToDtoMapper<Chan
private BranchReferenceDto createBranchReferenceDto(Repository repository, String branchName) {
BranchReferenceDto branchReferenceDto = new BranchReferenceDto();
branchReferenceDto.setName(branchName);
branchReferenceDto.add(Links.linkingTo().self(resourceLinks.branch().self(repository.getNamespaceAndName(), branchName)).build());
branchReferenceDto.add(Links.linkingTo().self(resourceLinks.branch().self(repository.getNamespace(), repository.getName(), branchName)).build());
return branchReferenceDto;
}
}

View File

@@ -39,6 +39,6 @@ public class DefaultBranchLinkProvider implements BranchLinkProvider {
@Override
public String get(NamespaceAndName namespaceAndName, String branch) {
return resourceLinks.branch().self(namespaceAndName, branch);
return resourceLinks.branch().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), branch);
}
}

View File

@@ -132,7 +132,7 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
}
}
if (repositoryService.isSupported(Command.BRANCHES)) {
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name,
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(repository,
getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId()))));
}

View File

@@ -24,14 +24,14 @@
package sonia.scm.api.v2.resources;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.security.gpg.UserPublicKeyResource;
import javax.inject.Inject;
import java.net.URI;
import java.net.URISyntaxException;
@SuppressWarnings("squid:S1192") // string literals should not be duplicated
@SuppressWarnings("squid:S1192")
// string literals should not be duplicated
class ResourceLinks {
private final ScmPathInfoStore scmPathInfoStore;
@@ -273,13 +273,13 @@ class ResourceLinks {
}
AutoCompleteLinks autoComplete() {
return new AutoCompleteLinks (scmPathInfoStore.get());
return new AutoCompleteLinks(scmPathInfoStore.get());
}
static class AutoCompleteLinks {
static class AutoCompleteLinks {
private final LinkBuilder linkBuilder;
AutoCompleteLinks (ScmPathInfo pathInfo) {
AutoCompleteLinks(ScmPathInfo pathInfo) {
linkBuilder = new LinkBuilder(pathInfo, AutoCompleteResource.class);
}
@@ -485,17 +485,21 @@ class ResourceLinks {
branchLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, BranchRootResource.class);
}
String self(NamespaceAndName namespaceAndName, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("get").parameters(branch).href();
String self(String namespace, String name, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branches").parameters().method("get").parameters(branch).href();
}
public String history(NamespaceAndName namespaceAndName, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("history").parameters(branch).href();
public String history(String namespace, String name, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branches").parameters().method("history").parameters(branch).href();
}
public String create(String namespace, String name) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branches").parameters().method("create").parameters().href();
}
public String delete(String namespace, String name, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branches").parameters().method("delete").parameters(branch).href();
}
}
public IncomingLinks incoming() {
@@ -510,11 +514,11 @@ class ResourceLinks {
}
public String changesets(String namespace, String name) {
return toTemplateParams(incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingChangesets").parameters("source","target").href());
return toTemplateParams(incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingChangesets").parameters("source", "target").href());
}
public String changesets(String namespace, String name, String source, String target) {
return incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingChangesets").parameters(source,target).href();
return incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingChangesets").parameters(source, target).href();
}
public String diff(String namespace, String name) {
@@ -591,6 +595,7 @@ class ResourceLinks {
ModificationsLinks(ScmPathInfo pathInfo) {
modificationsLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, ModificationsRootResource.class);
}
String self(String namespace, String name, String revision) {
return modificationsLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("modifications").parameters().method("get").parameters(revision).href();
}

View File

@@ -24,8 +24,8 @@
package sonia.scm.api.v2.resources;
import com.google.inject.util.Providers;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.ThreadContext;
@@ -56,11 +56,12 @@ import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
@@ -68,6 +69,7 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -271,6 +273,62 @@ public class BranchRootResourceTest extends RepositoryTestBase {
verify(branchCommandBuilder, never()).branch(anyString());
}
@Test
public void shouldNotDeleteBranchIfNotPermitted() throws IOException, URISyntaxException {
doThrow(AuthorizationException.class).when(subject).checkPermission("repository:modify:repoId");
when(branchesCommandBuilder.getBranches()).thenReturn(new Branches(Branch.normalBranch("suspicious", "0")));
MockHttpRequest request = MockHttpRequest
.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/suspicious");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(403, response.getStatus());
verify(branchCommandBuilder, never()).delete("suspicious");
}
@Test
public void shouldNotDeleteDefaultBranch() throws IOException, URISyntaxException {
when(branchesCommandBuilder.getBranches()).thenReturn(new Branches(Branch.defaultBranch("main", "0")));
MockHttpRequest request = MockHttpRequest
.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/main");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(400, response.getStatus());
}
@Test
public void shouldDeleteBranch() throws IOException, URISyntaxException {
when(branchesCommandBuilder.getBranches()).thenReturn(new Branches(Branch.normalBranch("suspicious", "0")));
MockHttpRequest request = MockHttpRequest
.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/suspicious");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(204, response.getStatus());
verify(branchCommandBuilder).delete("suspicious");
}
@Test
public void shouldAnswer204IfNothingWasDeleted() throws IOException, URISyntaxException {
when(branchesCommandBuilder.getBranches()).thenReturn(new Branches());
MockHttpRequest request = MockHttpRequest
.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/suspicious");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(204, response.getStatus());
verify(branchCommandBuilder, never()).delete(anyString());
}
private Branch createBranch(String existing_branch) {
return Branch.normalBranch(existing_branch, REVISION);
}

View File

@@ -24,28 +24,49 @@
package sonia.scm.api.v2.resources;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Branch;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import java.net.URI;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BranchToBranchDtoMapperTest {
private final URI baseUri = URI.create("https://hitchhiker.com");
private final URI baseUri = URI.create("https://hitchhiker.com/api/");
@SuppressWarnings("unused") // Is injected
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@Mock
private Subject subject;
@InjectMocks
private BranchToBranchDtoMapperImpl mapper;
@BeforeEach
void setupSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void tearDown() {
ThreadContext.unbindSubject();
}
@Test
void shouldAppendLinks() {
HalEnricherRegistry registry = new HalEnricherRegistry();
@@ -59,7 +80,37 @@ class BranchToBranchDtoMapperTest {
Branch branch = Branch.normalBranch("master", "42");
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");
BranchDto dto = mapper.map(branch, RepositoryTestData.createHeartOfGold());
assertThat(dto.getLinks().getLinkBy("ka").get().getHref()).isEqualTo("http://hitchhiker/HeartOfGold/master");
}
@Test
void shouldAppendDeleteLink() {
Repository repository = RepositoryTestData.createHeartOfGold();
when(subject.isPermitted("repository:modify:" + repository.getId())).thenReturn(true);
Branch branch = Branch.normalBranch("master", "42");
BranchDto dto = mapper.map(branch, repository);
assertThat(dto.getLinks().getLinkBy("delete").get().getHref()).isEqualTo("https://hitchhiker.com/api/v2/repositories/hitchhiker/HeartOfGold/branches/master");
}
@Test
void shouldNotAppendDeleteLinkIfDefaultBranch() {
Repository repository = RepositoryTestData.createHeartOfGold();
Branch branch = Branch.defaultBranch("master", "42");
BranchDto dto = mapper.map(branch, repository);
assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
}
@Test
void shouldNotAppendDeleteLinkIfNotPermitted() {
Repository repository = RepositoryTestData.createHeartOfGold();
when(subject.isPermitted("repository:modify:" + repository.getId())).thenReturn(false);
Branch branch = Branch.normalBranch("master", "42");
BranchDto dto = mapper.map(branch, repository);
assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
}
}

View File

@@ -140,13 +140,13 @@ public class ResourceLinksTest {
@Test
public void shouldCreateCorrectBranchUrl() {
String url = resourceLinks.branch().self(new NamespaceAndName("space", "name"), "master");
String url = resourceLinks.branch().self("space", "name", "master");
assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/branches/master", url);
}
@Test
public void shouldCreateCorrectBranchHiostoryUrl() {
String url = resourceLinks.branch().history(new NamespaceAndName("space", "name"), "master");
String url = resourceLinks.branch().history("space", "name", "master");
assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/branches/master/changesets/", url);
}