value: excluded,
}).catch(throwGlobalError);
}
+
+export function setMainBranch(project: string, branch: string) {
+ return post('/api/project_branches/set_main', { project, branch }).catch(throwGlobalError);
+}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { cloneDeep } from 'lodash';
-import { mockBranch, mockPullRequest } from '../../helpers/mocks/branch-like';
import { Branch, PullRequest } from '../../types/branch-like';
import {
deleteBranch,
getBranches,
getPullRequests,
renameBranch,
+ setMainBranch,
} from '../branches';
+import { mockBranchList, mockPullRequestList } from './data/branches';
jest.mock('../branches');
-const defaultBranches: Branch[] = [
- mockBranch({ isMain: true, name: 'main', status: { qualityGateStatus: 'OK' } }),
- mockBranch({
- excludedFromPurge: false,
- name: 'delete-branch',
- analysisDate: '2018-01-30',
- status: { qualityGateStatus: 'ERROR' },
- }),
- mockBranch({ name: 'normal-branch', status: { qualityGateStatus: 'ERROR' } }),
-];
-
-const defaultPullRequests: PullRequest[] = [
- mockPullRequest({
- title: 'TEST-191 update master',
- key: '01',
- status: { qualityGateStatus: 'OK' },
- }),
- mockPullRequest({
- title: 'TEST-192 update normal-branch',
- key: '02',
- analysisDate: '2018-01-30',
- base: 'normal-branch',
- target: 'normal-branch',
- status: { qualityGateStatus: 'ERROR' },
- }),
- mockPullRequest({
- title: 'TEST-193 dumb commit',
- key: '03',
- target: 'normal-branch',
- status: { qualityGateStatus: 'ERROR' },
- }),
-];
-
export default class BranchesServiceMock {
branches: Branch[];
pullRequests: PullRequest[];
constructor() {
- this.branches = cloneDeep(defaultBranches);
- this.pullRequests = cloneDeep(defaultPullRequests);
+ this.branches = mockBranchList();
+ this.pullRequests = mockPullRequestList();
+
jest.mocked(getBranches).mockImplementation(this.getBranchesHandler);
jest.mocked(getPullRequests).mockImplementation(this.getPullRequestsHandler);
jest.mocked(deleteBranch).mockImplementation(this.deleteBranchHandler);
jest.mocked(deletePullRequest).mockImplementation(this.deletePullRequestHandler);
jest.mocked(renameBranch).mockImplementation(this.renameBranchHandler);
jest.mocked(excludeBranchFromPurge).mockImplementation(this.excludeBranchFromPurgeHandler);
+ jest.mocked(setMainBranch).mockImplementation(this.setMainBranchHandler);
}
getBranchesHandler = () => {
deleteBranchHandler: typeof deleteBranch = ({ branch }) => {
this.branches = this.branches.filter((b) => b.name !== branch);
- return this.reply({});
+ return this.reply(null);
};
deletePullRequestHandler: typeof deletePullRequest = ({ pullRequest }) => {
this.pullRequests = this.pullRequests.filter((b) => b.key !== pullRequest);
- return this.reply({});
+ return this.reply(null);
};
renameBranchHandler: typeof renameBranch = (_, name) => {
this.branches = this.branches.map((b) => (b.isMain ? { ...b, name } : b));
- return this.reply({});
+ return this.reply(null);
};
excludeBranchFromPurgeHandler: typeof excludeBranchFromPurge = (_, name, value) => {
this.branches = this.branches.map((b) =>
b.name === name ? { ...b, excludedFromPurge: value } : b
);
- return this.reply({});
+ return this.reply(null);
+ };
+
+ setMainBranchHandler: typeof setMainBranch = (_, branch) => {
+ this.branches = this.branches.map((b) => ({
+ ...b,
+ excludedFromPurge: b.excludedFromPurge || b.isMain || b.name === branch,
+ isMain: b.name === branch,
+ }));
+ return this.reply(null);
};
emptyBranches = () => {
};
reset = () => {
- this.branches = cloneDeep(defaultBranches);
- this.pullRequests = cloneDeep(defaultPullRequests);
+ this.branches = mockBranchList();
+ this.pullRequests = mockPullRequestList();
};
reply<T>(response: T): Promise<T> {
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { mockBranch, mockPullRequest } from '../../../helpers/mocks/branch-like';
+
+export function mockBranchList() {
+ return [
+ mockBranch({
+ isMain: true,
+ name: 'main',
+ status: { qualityGateStatus: 'OK' },
+ }),
+ mockBranch({
+ excludedFromPurge: false,
+ name: 'delete-branch',
+ analysisDate: '2018-01-30',
+ status: { qualityGateStatus: 'ERROR' },
+ }),
+ mockBranch({ name: 'normal-branch', status: { qualityGateStatus: 'ERROR' } }),
+ ];
+}
+
+export function mockPullRequestList() {
+ return [
+ mockPullRequest({
+ title: 'TEST-191 update master',
+ key: '01',
+ status: { qualityGateStatus: 'OK' },
+ }),
+ mockPullRequest({
+ title: 'TEST-192 update normal-branch',
+ key: '02',
+ analysisDate: '2018-01-30',
+ base: 'normal-branch',
+ target: 'normal-branch',
+ status: { qualityGateStatus: 'ERROR' },
+ }),
+ mockPullRequest({
+ title: 'TEST-193 dumb commit',
+ key: '03',
+ target: 'normal-branch',
+ status: { qualityGateStatus: 'ERROR' },
+ }),
+ ];
+}
import { Helmet } from 'react-helmet-async';
import withComponentContext from '../../app/components/componentContext/withComponentContext';
import { translate } from '../../helpers/l10n';
+import { withBranchLikes } from '../../queries/branch';
import { Component } from '../../types/types';
import BranchLikeTabs from './components/BranchLikeTabs';
import LifetimeInformation from './components/LifetimeInformation';
);
}
-export default withComponentContext(React.memo(ProjectBranchesApp));
+export default withComponentContext(withBranchLikes(React.memo(ProjectBranchesApp)));
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-
import { act, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
branchRow = this.branchTabContent.byRole('row');
pullRequestRow = this.pullRequestTabContent.byRole('row');
+
+ getBranchRow = (name: string | RegExp) =>
+ within(this.branchTabContent.get()).getByRole('row', { name });
+
+ setMainBranchBtn = byRole('button', { name: 'project_branch_pull_request.branch.set_main' });
+ dialog = byRole('dialog');
})();
beforeEach(() => {
expect(ui.pullRequestTabContent.query()).not.toBeInTheDocument();
expect(ui.linkForAdmin.query()).not.toBeInTheDocument();
expect(await ui.branchRow.findAll()).toHaveLength(4);
- expect(ui.branchRow.getAt(1)).toHaveTextContent('mainbranches.main_branchOK1 month ago');
+ expect(ui.branchRow.getAt(1)).toHaveTextContent(
+ 'mainbranches.main_branchOK1 month agoproject_branch_pull_request.branch.auto_deletion.main_branch_tooltip'
+ );
expect(within(ui.branchRow.getAt(1)).getByRole('switch')).toBeDisabled();
expect(within(ui.branchRow.getAt(1)).getByRole('switch')).toBeChecked();
expect(ui.branchRow.getAt(2)).toHaveTextContent('delete-branchERROR2 days ago');
expect(
within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' })
).toBeDisabled();
- await user.type(within(ui.renameBranchDialog.get()).getByRole('textbox'), 'master');
+ await user.type(within(ui.renameBranchDialog.get()).getByRole('textbox'), 'develop');
expect(within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' })).toBeEnabled();
await act(() =>
user.click(within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' }))
);
- expect(ui.branchRow.getAt(1)).toHaveTextContent('masterbranches.main_branchOK1 month ago');
+ expect(ui.branchRow.getAt(1)).toHaveTextContent(
+ 'developbranches.main_branchOK1 month agoproject_branch_pull_request.branch.auto_deletion.main_branch_tooltip'
+ );
await user.click(await ui.updateSecondBranchBtn.find());
expect(ui.renameBranchBtn.query()).not.toBeInTheDocument();
});
+it('should be able to set a branch as the main branch', async () => {
+ const user = userEvent.setup();
+ renderProjectBranchesApp();
+
+ // Cannot set main branch as main branch.
+ await user.click(await ui.updateMasterBtn.find());
+ expect(ui.setMainBranchBtn.query()).not.toBeInTheDocument();
+ expect(ui.getBranchRow(/^main/)).toBeInTheDocument();
+ expect(within(ui.getBranchRow(/^main/)).getByText('branches.main_branch')).toBeInTheDocument();
+ expect(within(ui.getBranchRow(/^main/)).getByRole('switch')).toBeChecked();
+ expect(within(ui.getBranchRow(/^main/)).getByRole('switch')).toBeDisabled();
+
+ // Change main branch.
+ await user.click(await ui.updateSecondBranchBtn.find());
+ await user.click(ui.setMainBranchBtn.get());
+ await act(async () => {
+ await user.click(
+ within(ui.dialog.get()).getByRole('button', {
+ name: 'project_branch_pull_request.branch.set_x_as_main.delete-branch',
+ })
+ );
+ });
+
+ // "delete-branch" is now the main branch.
+ expect(ui.getBranchRow(/delete-branch/)).toBeInTheDocument();
+ expect(
+ within(ui.getBranchRow(/delete-branch/)).getByText('branches.main_branch')
+ ).toBeInTheDocument();
+ expect(within(ui.getBranchRow(/delete-branch/)).getByRole('switch')).toBeChecked();
+ expect(within(ui.getBranchRow(/delete-branch/)).getByRole('switch')).toBeDisabled();
+
+ // "main" is now excluded from purge
+ expect(within(ui.getBranchRow(/^main/)).getByRole('switch')).toBeChecked();
+});
+
it('should be able to delete branch, but not main', async () => {
const user = userEvent.setup();
renderProjectBranchesApp();
displayPurgeSetting?: boolean;
onDelete: () => void;
onRename: () => void;
+ onSetAsMain: () => void;
}
function BranchLikeRow(props: BranchLikeRowProps) {
getBranchLikeDisplayName(branchLike)
)}
>
+ {isBranch(branchLike) && !isMainBranch(branchLike) && (
+ <ActionsDropdownItem onClick={props.onSetAsMain}>
+ {translate('project_branch_pull_request.branch.set_main')}
+ </ActionsDropdownItem>
+ )}
+
{isMainBranch(branchLike) ? (
<ActionsDropdownItem className="js-rename" onClick={props.onRename}>
{translate('project_branch_pull_request.branch.rename')}
displayPurgeSetting?: boolean;
onDelete: (branchLike: BranchLike) => void;
onRename: (branchLike: BranchLike) => void;
+ onSetAsMain: (branchLike: BranchLike) => void;
title: string;
}
key={getBranchLikeKey(branchLike)}
onDelete={() => props.onDelete(branchLike)}
onRename={() => props.onRename(branchLike)}
+ onSetAsMain={() => props.onSetAsMain(branchLike)}
/>
))}
</tbody>
import BranchLikeTable from './BranchLikeTable';
import DeleteBranchModal from './DeleteBranchModal';
import RenameBranchModal from './RenameBranchModal';
+import SetAsMainBranchModal from './SetAsMainBranchModal';
interface Props {
component: Component;
const { component } = props;
const [currentTab, setCurrentTab] = useState<Tabs>(Tabs.Branch);
const [renaming, setRenaming] = useState<BranchLike>();
-
+ const [settingAsMain, setSettingAsMain] = useState<Branch>();
const [deleting, setDeleting] = useState<BranchLike>();
const handleClose = () => {
setRenaming(undefined);
setDeleting(undefined);
+ setSettingAsMain(undefined);
+ };
+
+ const handleSetAsMainBranch = (branchLike: BranchLike) => {
+ if (isBranch(branchLike)) {
+ setSettingAsMain(branchLike);
+ }
};
const { data: { branchLikes } = { branchLikes: [] } } = useBranchesQuery(component);
displayPurgeSetting={isBranchMode}
onDelete={setDeleting}
onRename={setRenaming}
+ onSetAsMain={handleSetAsMainBranch}
title={title}
/>
</div>
{renaming && isMainBranch(renaming) && (
<RenameBranchModal branch={renaming} component={component} onClose={handleClose} />
)}
+
+ {settingAsMain && !isMainBranch(settingAsMain) && (
+ <SetAsMainBranchModal
+ branch={settingAsMain}
+ component={component}
+ onClose={handleClose}
+ onSetAsMain={handleClose}
+ />
+ )}
</>
);
}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
+import { useEffect } from 'react';
import HelpTooltip from '../../../components/controls/HelpTooltip';
import Toggle from '../../../components/controls/Toggle';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
const { branch, component } = props;
const { mutate: excludeFromPurge, isLoading } = useExcludeFromPurgeMutation();
+ useEffect(() => {
+ excludeFromPurge({ component, key: branch.name, exclude: branch.excludedFromPurge });
+ }, [branch.excludedFromPurge]);
+
const handleOnChange = (exclude: boolean) => {
excludeFromPurge({ component, key: branch.name, exclude });
};
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { ButtonPrimary, FlagMessage, Modal } from 'design-system';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import DocLink from '../../../components/common/DocLink';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { useSetMainBranchMutation } from '../../../queries/branch';
+import { Branch } from '../../../types/branch-like';
+import { Component } from '../../../types/types';
+
+interface SetAsMainBranchModalProps {
+ branch: Branch;
+ component: Component;
+ onClose: () => void;
+ onSetAsMain: () => void;
+}
+
+export default function SetAsMainBranchModal(props: SetAsMainBranchModalProps) {
+ const { branch, component, onClose, onSetAsMain } = props;
+ const { mutate: setMainBranch, isLoading } = useSetMainBranchMutation();
+
+ const handleClick = () => {
+ setMainBranch({ component, branchName: branch.name }, { onSuccess: onSetAsMain });
+ };
+
+ return (
+ <Modal
+ headerTitle={translateWithParameters(
+ 'project_branch_pull_request.branch.set_x_as_main',
+ branch.name
+ )}
+ loading={isLoading}
+ onClose={onClose}
+ body={
+ <>
+ <p className="sw-mb-4">
+ {translateWithParameters(
+ 'project_branch_pull_request.branch.main_branch.are_you_sure',
+ branch.name
+ )}
+ </p>
+ <p className="sw-mb-4">
+ <FormattedMessage
+ id="project_branch_pull_request.branch.main_branch.learn_more"
+ defaultMessage={translate(
+ 'project_branch_pull_request.branch.main_branch.learn_more'
+ )}
+ values={{
+ documentation: (
+ <DocLink to="/analyzing-source-code/branches/branch-analysis/#main-branch">
+ {translate('documentation')}
+ </DocLink>
+ ),
+ }}
+ />
+ </p>
+ <FlagMessage variant="warning">
+ {translate('project_branch_pull_request.branch.main_branch.requires_reindex')}
+ </FlagMessage>
+ </>
+ }
+ primaryButton={
+ <ButtonPrimary disabled={isLoading} onClick={handleClick}>
+ {translateWithParameters('project_branch_pull_request.branch.set_x_as_main', branch.name)}
+ </ButtonPrimary>
+ }
+ secondaryButtonLabel={translate('cancel')}
+ />
+ );
+}
getBranches,
getPullRequests,
renameBranch,
+ setMainBranch,
} from '../api/branches';
import { dismissAnalysisWarning, getAnalysisStatus } from '../api/ce';
import { getQualityGateProjectStatus } from '../api/quality-gates';
});
}
+export function useSetMainBranchMutation() {
+ type SetAsMainBranchArg = { branchName: string; component: Component };
+ const queryClient = useQueryClient();
+ const invalidateKey = useMutateBranchQueryKey();
+
+ return useMutation({
+ mutationFn: async ({ component, branchName }: SetAsMainBranchArg) => {
+ await setMainBranch(component.key, branchName);
+ },
+ onSuccess() {
+ queryClient.invalidateQueries({ queryKey: invalidateKey });
+ },
+ });
+}
+
/**
* Helper functions that sould be avoid. Instead convert the component into functional
* and/or use proper react-query
project_branch_pull_request.lifetime_information=Branches and Pull Requests are permanently deleted after {days} days without analysis.
project_branch_pull_request.lifetime_information.admin=You can adjust this value globally in {settings}.
project_branch_pull_request.branch.rename=Rename branch
+project_branch_pull_request.branch.set_main=Set as main branch
+project_branch_pull_request.branch.set_x_as_main=Set "{0}" as the main branch
project_branch_pull_request.branch.delete=Delete branch
project_branch_pull_request.branch.actions_label=Update {0}
project_branch_pull_request.branch.delete.are_you_sure=Are you sure you want to delete branch "{0}"?
+project_branch_pull_request.branch.main_branch.are_you_sure=Are you sure you want to set branch "{0}" as the main branch of this project?
+project_branch_pull_request.branch.main_branch.requires_reindex=Changing the main branch of your project will trigger a project re-indexation and may impact the level of information that is available until re-indexing is complete.
+project_branch_pull_request.branch.main_branch.learn_more=Please refer to the {documentation} to understand the impacts of changing the main branch.
project_branch_pull_request.branch.auto_deletion.keep_when_inactive=Keep when inactive
project_branch_pull_request.branch.auto_deletion.keep_when_inactive.tooltip=When turned on, the branch will not be automatically deleted when inactive.
project_branch_pull_request.branch.auto_deletion.main_branch_tooltip=The main branch is always excluded from automatic deletion.