Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/commands/jira/startWorkOnIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ import { FeatureFlagClient, Features } from '../../util/featureFlags';
export async function startWorkOnIssue(issueOrKeyAndSite: MinimalIssueOrKeyAndSite<DetailedSiteInfo>) {
let issue: MinimalIssue<DetailedSiteInfo>;

if (isMinimalIssue(issueOrKeyAndSite)) {
issue = issueOrKeyAndSite;
} else {
if (FeatureFlagClient.checkGate(Features.StartWorkV3)) {
issue = await fetchMinimalIssue(issueOrKeyAndSite.key, issueOrKeyAndSite.siteDetails);

if (!issue) {
throw new Error(`Jira issue ${issueOrKeyAndSite.key} not found in site ${issueOrKeyAndSite.siteDetails}`);
} else {
if (isMinimalIssue(issueOrKeyAndSite)) {
issue = issueOrKeyAndSite;
} else {
issue = await fetchMinimalIssue(issueOrKeyAndSite.key, issueOrKeyAndSite.siteDetails);
}
}

if (!issue) {
throw new Error(`Jira issue ${issueOrKeyAndSite.key} not found in site ${issueOrKeyAndSite.siteDetails}`);
}

const { startWorkV3WebviewFactory, startWorkWebviewFactory } = Container;

const factory = FeatureFlagClient.checkGate(Features.StartWorkV3)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createEmptyMinimalIssue, MinimalIssue, Transition } from '@atlassianlab
import { DetailedSiteInfo, emptySiteInfo, ProductBitbucket } from '../../../../atlclients/authInfo';
import { BitbucketBranchingModel, WorkspaceRepo } from '../../../../bitbucket/model';
import { Container } from '../../../../container';
import { FeatureFlagClient } from '../../../../util/featureFlags';
import { AnalyticsApi } from '../../../analyticsApi';
import { CommonActionType } from '../../../ipc/fromUI/common';
import { StartWorkAction, StartWorkActionType } from '../../../ipc/fromUI/startWork';
Expand All @@ -27,6 +28,14 @@ import { StartWorkWebviewController } from './startWorkWebviewController';
jest.mock('@atlassianlabs/guipi-core-controller');
jest.mock('../../../../container');
jest.mock('../../formatError');
jest.mock('../../../../util/featureFlags', () => ({
FeatureFlagClient: {
checkGate: jest.fn(),
},
Features: {
StartWorkV3: 'startWorkV3',
},
}));

describe('StartWorkWebviewController', () => {
let controller: StartWorkWebviewController;
Expand Down Expand Up @@ -152,6 +161,9 @@ describe('StartWorkWebviewController', () => {

(formatError as jest.Mock).mockReturnValue('Formatted error message');

// Mock FeatureFlagClient to return false by default (old version)
(FeatureFlagClient.checkGate as jest.Mock).mockReturnValue(false);

controller = new StartWorkWebviewController(
mockMessagePoster,
mockApi,
Expand Down Expand Up @@ -453,7 +465,10 @@ describe('StartWorkWebviewController', () => {
});
});

it('should refresh and post init message with repo data', async () => {
it('should refresh and post init message with repo data (old version - includes customBranchType)', async () => {
// Mock FeatureFlagClient to return false (old version)
(FeatureFlagClient.checkGate as jest.Mock).mockReturnValue(false);

await controller.onMessageReceived({ type: CommonActionType.Refresh });

expect(mockApi.getWorkspaceRepos).toHaveBeenCalled();
Expand Down Expand Up @@ -483,6 +498,38 @@ describe('StartWorkWebviewController', () => {
});
});

it('should refresh and post init message with repo data (new version - excludes customBranchType)', async () => {
// Mock FeatureFlagClient to return true (new version)
(FeatureFlagClient.checkGate as jest.Mock).mockReturnValue(true);

await controller.onMessageReceived({ type: CommonActionType.Refresh });

expect(mockApi.getWorkspaceRepos).toHaveBeenCalled();
expect(mockApi.getRepoDetails).toHaveBeenCalledWith(mockWorkspaceRepo);
expect(mockApi.getRepoScmState).toHaveBeenCalledWith(mockWorkspaceRepo);
expect(mockMessagePoster).toHaveBeenCalledWith({
type: StartWorkMessageType.Init,
issue: mockIssue,
repoData: expect.arrayContaining([
expect.objectContaining({
workspaceRepo: mockWorkspaceRepo,
href: 'https://test.atlassian.net/projects/test/repos/repo',
branchTypes: expect.arrayContaining([
{ kind: 'bugfix', prefix: 'bugfix/' },
{ kind: 'feature', prefix: 'feature/' },
]),
developmentBranch: 'develop',
isCloud: true,
userName: 'testuser',
userEmail: '[email protected]',
hasSubmodules: false,
}),
]),
customTemplate: '{issueKey}',
customPrefixes: ['feature/', 'bugfix/'],
});
});

it('should filter out repos without site remotes', async () => {
const repoWithoutRemotes = { ...mockWorkspaceRepo, siteRemotes: [] };
mockApi.getWorkspaceRepos.mockReturnValue([repoWithoutRemotes]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ProductBitbucket } from '../../../../atlclients/authInfo';
import { BitbucketBranchingModel } from '../../../../bitbucket/model';
import { Commands } from '../../../../constants';
import { Container } from '../../../../container';
import { FeatureFlagClient, Features } from '../../../../util/featureFlags';
import { OnJiraEditedRefreshDelay } from '../../../../util/time';
import { AnalyticsApi } from '../../../analyticsApi';
import { CommonActionType } from '../../../ipc/fromUI/common';
Expand Down Expand Up @@ -84,7 +85,8 @@ export class StartWorkWebviewController implements WebviewController<StartWorkIs
return a.kind.localeCompare(b.kind);
},
),
customBranchType,
// Only add customBranchType for old version (not V3)
...(FeatureFlagClient.checkGate(Features.StartWorkV3) ? [] : [customBranchType]),
];
const developmentBranch = repoDetails.developmentBranch;
const href = repoDetails.url;
Expand Down
83 changes: 75 additions & 8 deletions src/react/atlascode/startwork/v3/StartWorkPageV3.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { Box, Button, Typography } from '@mui/material';
import React from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import React, { useContext } from 'react';
import { AnalyticsView } from 'src/analyticsTypes';

import { AtlascodeErrorBoundary } from '../../common/ErrorBoundary';
import { ErrorStateContext } from '../../common/errorController';
import { ErrorDisplay } from '../../common/ErrorDisplay';
import { StartWorkControllerContext, useStartWorkController } from '../startWorkController';
import { CreateBranchSection, TaskInfoSection, UpdateStatusSection } from './components';
import {
CreateBranchSection,
SnackbarNotification,
SuccessAlert,
TaskInfoSection,
UpdateStatusSection,
} from './components';
import { useStartWorkFormState } from './hooks/useStartWorkFormState';

const StartWorkPageV3: React.FunctionComponent = () => {
const [state, controller] = useStartWorkController();
const errorState = useContext(ErrorStateContext);

const {
formState,
formActions,
updateStatusFormState,
updateStatusFormActions,
handleCreateBranch,
handleSnackbarClose,
submitState,
submitResponse,
snackbarOpen,
} = useStartWorkFormState(state, controller);

return (
<StartWorkControllerContext.Provider value={controller}>
Expand All @@ -22,13 +44,58 @@ const StartWorkPageV3: React.FunctionComponent = () => {
</Typography>
</Box>

{submitState === 'submit-success' && <SuccessAlert submitResponse={submitResponse} />}

{errorState.isErrorBannerOpen && (
<Box marginBottom={2}>
<ErrorDisplay />
</Box>
)}

<TaskInfoSection state={state} controller={controller} />
<CreateBranchSection state={state} controller={controller} />
<UpdateStatusSection state={state} controller={controller} />
<Button variant="contained" color="primary">
Create branch
</Button>
<CreateBranchSection
state={state}
controller={controller}
formState={formState}
formActions={formActions}
/>
<UpdateStatusSection
state={state}
controller={controller}
formState={updateStatusFormState}
formActions={updateStatusFormActions}
/>

{submitState !== 'submit-success' && (
<Button
variant="contained"
color="primary"
disabled={submitState === 'submitting'}
onClick={handleCreateBranch}
endIcon={
submitState === 'submitting' ? <CircularProgress color="inherit" size={20} /> : null
}
>
Create branch
</Button>
)}

{submitState === 'submit-success' && (
<Button variant="contained" color="inherit" onClick={controller.closePage}>
Close
</Button>
)}
</Box>

{submitState === 'submit-success' && (
<SnackbarNotification
open={snackbarOpen}
onClose={handleSnackbarClose}
title="Success!"
message="See details at the top of this page"
severity="success"
/>
)}
</AtlascodeErrorBoundary>
</StartWorkControllerContext.Provider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react';
import React from 'react';

import { RepoData } from '../../../../../lib/ipc/toUI/startWork';
import { BranchPrefixSelector } from './BranchPrefixSelector';

describe('BranchPrefixSelector', () => {
const mockRepoData: RepoData = {
workspaceRepo: {
rootUri: '/test/repo',
mainSiteRemote: { site: undefined, remote: { name: 'origin', isReadOnly: false } },
siteRemotes: [{ site: undefined, remote: { name: 'origin', isReadOnly: false } }],
},
localBranches: [],
remoteBranches: [],
branchTypes: [
{ kind: 'Feature', prefix: 'feature/' },
{ kind: 'Bugfix', prefix: 'bugfix/' },
],
developmentBranch: 'main',
userName: 'test',
userEmail: '[email protected]',
isCloud: false,
};

it('should not render when no branch types or custom prefixes', () => {
const repoWithoutBranchTypes = {
...mockRepoData,
branchTypes: [],
};

const { container } = render(
<BranchPrefixSelector
selectedRepository={repoWithoutBranchTypes}
selectedBranchType={{ kind: 'Feature', prefix: 'feature/' }}
customPrefixes={[]}
onBranchTypeChange={jest.fn()}
/>,
);

expect(container.firstChild).toBeNull();
});

it('should render with branch types', () => {
render(
<BranchPrefixSelector
selectedRepository={mockRepoData}
selectedBranchType={{ kind: 'Feature', prefix: 'feature/' }}
customPrefixes={[]}
onBranchTypeChange={jest.fn()}
/>,
);

expect(screen.getByText('Branch prefix')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Autocomplete, Grid, TextField, Typography } from '@mui/material';
import React, { useCallback } from 'react';

import { RepoData } from '../../../../../lib/ipc/toUI/startWork';

interface BranchPrefixSelectorProps {
selectedRepository: RepoData | undefined;
selectedBranchType: { kind: string; prefix: string };
customPrefixes: string[];
onBranchTypeChange: (branchType: { kind: string; prefix: string }) => void;
}

export const BranchPrefixSelector: React.FC<BranchPrefixSelectorProps> = ({
selectedRepository,
selectedBranchType,
customPrefixes,
onBranchTypeChange,
}) => {
// Convert custom prefixes to branch types format
const convertedCustomPrefixes = customPrefixes.map((prefix) => {
const normalizedCustomPrefix = prefix.endsWith('/') ? prefix : prefix + '/';
return { prefix: normalizedCustomPrefix, kind: prefix };
});

const handleBranchTypeChange = useCallback(
(event: React.ChangeEvent<{}>, value: { kind: string; prefix: string } | null) => {
if (value) {
onBranchTypeChange(value);
}
},
[onBranchTypeChange],
);

const hasOptions = (selectedRepository?.branchTypes?.length || 0) > 0 || convertedCustomPrefixes.length > 0;

if (!hasOptions) {
return null;
}

return (
<Grid item xs={6}>
<Typography variant="body2">Branch prefix</Typography>
<Autocomplete
options={[...(selectedRepository?.branchTypes || []), ...convertedCustomPrefixes]}
groupBy={(option) =>
(selectedRepository?.branchTypes || []).map((type) => type.kind).includes(option.kind)
? 'Repo Branch Type'
: 'Custom Prefix'
}
getOptionLabel={(option) => option.kind}
renderInput={(params) => <TextField {...params} size="small" variant="outlined" />}
size="small"
disableClearable
value={selectedBranchType}
onChange={handleBranchTypeChange}
/>
</Grid>
);
};
Loading