Merge pull request #1422 from scm-manager/feature/delete_branches
Feature/delete branches
@@ -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))
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 158 KiB |
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 150 KiB |
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||