diff --git a/CHANGELOG.md b/CHANGELOG.md index 59fddd12c3..dd06aa41ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/de/user/repo/assets/repository-branch-detailView.png b/docs/de/user/repo/assets/repository-branch-detailView.png index a73b369e26..d671846fef 100644 Binary files a/docs/de/user/repo/assets/repository-branch-detailView.png and b/docs/de/user/repo/assets/repository-branch-detailView.png differ diff --git a/docs/de/user/repo/assets/repository-branches-overview.png b/docs/de/user/repo/assets/repository-branches-overview.png index 9258f4be97..39dcf5e424 100644 Binary files a/docs/de/user/repo/assets/repository-branches-overview.png and b/docs/de/user/repo/assets/repository-branches-overview.png differ diff --git a/docs/de/user/repo/branches.md b/docs/de/user/repo/branches.md index b52b6492b2..17afe92924 100644 --- a/docs/de/user/repo/branches.md +++ b/docs/de/user/repo/branches.md @@ -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) diff --git a/docs/en/user/repo/assets/repository-branch-detailView.png b/docs/en/user/repo/assets/repository-branch-detailView.png index 2660d58a82..615ba696c1 100644 Binary files a/docs/en/user/repo/assets/repository-branch-detailView.png and b/docs/en/user/repo/assets/repository-branch-detailView.png differ diff --git a/docs/en/user/repo/assets/repository-branches-overview.png b/docs/en/user/repo/assets/repository-branches-overview.png index 32560b4f31..e63ebab775 100644 Binary files a/docs/en/user/repo/assets/repository-branches-overview.png and b/docs/en/user/repo/assets/repository-branches-overview.png differ diff --git a/docs/en/user/repo/branches.md b/docs/en/user/repo/branches.md index 8c39eae6f0..370165710b 100644 --- a/docs/en/user/repo/branches.md +++ b/docs/en/user/repo/branches.md @@ -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) diff --git a/scm-ui/ui-components/src/modals/ConfirmAlert.tsx b/scm-ui/ui-components/src/modals/ConfirmAlert.tsx index 89f8f0a117..290f024b63 100644 --- a/scm-ui/ui-components/src/modals/ConfirmAlert.tsx +++ b/scm-ui/ui-components/src/modals/ConfirmAlert.tsx @@ -62,11 +62,11 @@ export const ConfirmAlert: FC = ({ title, message, buttons, close }) => { const footer = (
- {buttons.map((button, i) => ( -

+ {buttons.map((button, index) => ( +

handleClickButton(button)} > {button.label} diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index d97fc9493c..d95ef44970 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -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": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 5f83ad7ba1..59981282df 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -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": { diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx index baddb90afd..1acaa78159 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx @@ -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 = ({ baseUrl, branch }) => { +const BranchRow: FC = ({ baseUrl, branch, onDelete }) => { const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`; + const [t] = useTranslation("repos"); + + let deleteButton; + if ((branch?._links?.delete as Link)?.href) { + deleteButton = ( + onDelete(branch)}> + + + + + ); + } + return ( - + {branch.name} - + + {deleteButton} ); }; diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx index db23ca7ad0..312275b663 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchTable.tsx @@ -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 { - render() { - const { t } = this.props; - return ( +const BranchTable: FC = ({ baseUrl, branches, fetchBranches }) => { + const [t] = useTranslation("repos"); + const [showConfirmAlert, setShowConfirmAlert] = useState(false); + const [error, setError] = useState(); + const [branchToBeDeleted, setBranchToBeDeleted] = useState(); + + 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 ; + }); + } + return rowContent; + }; + + const confirmAlert = ( + deleteBranch() + }, + { + label: t("branch.delete.confirmAlert.cancel"), + onClick: () => abortDelete() + } + ]} + close={() => abortDelete()} + /> + ); + + return ( + <> + {showConfirmAlert && confirmAlert} + {error && } - {this.renderRow()} + {renderRow()}
{t("branches.table.branches")}
- ); - } + + ); +}; - renderRow() { - const { baseUrl, branches } = this.props; - let rowContent = null; - if (branches) { - rowContent = branches.map((branch, index) => { - return ; - }); - } - return rowContent; - } -} - -export default withTranslation("repos")(BranchTable); +export default BranchTable; diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx index 9a88c08c2d..32ff0d8f7a 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx @@ -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 { render() { const { repository, branch } = this.props; - return (

@@ -49,6 +49,7 @@ class BranchView extends React.Component { }} />
+
); } diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/BranchDangerZone.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/BranchDangerZone.tsx new file mode 100644 index 0000000000..9e6c0e167a --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/branches/containers/BranchDangerZone.tsx @@ -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 = ({ repository, branch }) => { + const [t] = useTranslation("repos"); + + const dangerZone = []; + + if (branch?._links?.delete) { + dangerZone.push(); + } + + if (dangerZone.length === 0) { + return null; + } + + return ( + <> +
+ + {dangerZone} + + ); +}; + +export default BranchDangerZone; diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx index cb2847a712..9a9aa02d24 100644 --- a/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx @@ -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; diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx index 0e2a01622b..8bbcac53bf 100644 --- a/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/containers/BranchesOverview.tsx @@ -81,10 +81,10 @@ class BranchesOverview extends React.Component { } renderBranchesTable() { - const { baseUrl, branches, t } = this.props; + const { baseUrl, branches, repository, fetchBranches, t } = this.props; if (branches && branches.length > 0) { orderBranches(branches); - return ; + return fetchBranches(repository)} />; } return {t("branches.overview.noBranches")}; } diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx new file mode 100644 index 0000000000..820c09740b --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx @@ -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 = ({ repository, branch }: Props) => { + const [showConfirmAlert, setShowConfirmAlert] = useState(false); + const [error, setError] = useState(); + 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 = ( + deleteBranch() + }, + { + label: t("branch.delete.confirmAlert.cancel"), + onClick: () => null + } + ]} + close={() => setShowConfirmAlert(false)} + /> + ); + } + + return ( + <> + + {showConfirmAlert && confirmAlert} + + {t("branch.delete.subtitle")} +
+ {t("branch.delete.description")} +

+ } + right={ setShowConfirmAlert(true)} />} + /> + + ); +}; + +export default DeleteBranch; diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index f2334c5d7e..2f91c8421b 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -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 { }} /> - + ); } diff --git a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx similarity index 93% rename from scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx rename to scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx index 0b2bda04b9..cb883a904f 100644 --- a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx @@ -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 = ({ repository, indexLinks }) => { +const RepositoryDangerZone: FC = ({ repository, indexLinks }) => { const [t] = useTranslation("repos"); const dangerZone = []; @@ -81,4 +81,4 @@ const DangerZone: FC = ({ repository, indexLinks }) => { ); }; -export default DangerZone; +export default RepositoryDangerZone; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchChangesetCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchChangesetCollectionToDtoMapper.java index 6cb1b1049b..96de0ce2dc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchChangesetCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchChangesetCollectionToDtoMapper.java @@ -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); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java index f640331381..bb0e93f27f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java @@ -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 branches) { return new HalRepresentation( createLinks(repository), - embedDtos(getBranchDtoList(repository.getNamespace(), repository.getName(), branches))); + embedDtos(getBranchDtoList(repository, branches))); } - public List getBranchDtoList(String namespace, String name, Collection branches) { - return branches.stream().map(branch -> branchToDtoMapper.map(branch, new NamespaceAndName(namespace, name))).collect(toList()); + public List getBranchDtoList(Repository repository, Collection branches) { + return branches.stream().map(branch -> branchToDtoMapper.map(branch, repository)).collect(toList()); } private Links createLinks(Repository repository) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java index 58261b4d15..a5ce4d951e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -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. + * + * Note: 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 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(); + } + } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java index f8088a79d5..f82c434b3d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java @@ -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); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperBase.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperBase.java index 0556b7a636..0e582309ad 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperBase.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperBase.java @@ -56,7 +56,7 @@ class ChangesetCollectionToDtoMapperBase extends PagedCollectionToDtoMapper Branch.normalBranch(branchName, source.getId())))); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index c826ee8e5e..7bd6c353f5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -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(); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java index 4178b0664f..7022422cea 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java @@ -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); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java index f1b44bee08..e46dc6eea3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java @@ -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(); + } + } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java index a0b1c9b7cb..9889d4df33 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java @@ -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); }