Skip to content

Commit

Permalink
Add config-as-code funtionality to import all
Browse files Browse the repository at this point in the history
  • Loading branch information
vbihun committed Feb 3, 2025
1 parent d3b081c commit d47b0c6
Show file tree
Hide file tree
Showing 10 changed files with 207 additions and 17 deletions.
42 changes: 38 additions & 4 deletions src/resolvers/admin-resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Resolver from '@forge/resolver';

import graphqlGateway from '@atlassian/forge-graphql';
import graphqlGateway, { Component } from '@atlassian/forge-graphql';
import { AuthErrorTypes, GitlabAPIGroup, ResolverResponse, DefaultErrorTypes, FeaturesList } from '../resolverTypes';
import { connectGroup, InvalidGroupTokenError } from '../services/group';

Expand All @@ -15,10 +15,11 @@ import {
getFeatures,
getGroupsProjects,
groupsAllExisting,
importProject,
webhookSetupConfig,
} from './shared-resolvers';
import { ConnectGroupInput, GitLabRoles, GroupProjectsResponse, WebhookSetupConfig } from '../types';
import { createMRWithCompassYML } from '../services/create-mr-with-compass-yml';
import { createComponent } from '../client/compass';

const resolver = new Resolver();

Expand Down Expand Up @@ -142,8 +143,24 @@ resolver.define('project/lastSyncTime', async (): Promise<ResolverResponse<strin
}
});

resolver.define('project/import', async (req): Promise<ResolverResponse> => {
return importProject(req);
resolver.define('createSingleComponent', async (req): Promise<ResolverResponse<Component>> => {
const {
payload: { projectToImport },
context: { cloudId },
} = req;
try {
const component = await createComponent(cloudId, projectToImport);

return {
success: true,
data: component,
};
} catch (e) {
return {
success: false,
errors: [{ message: e.message, errorType: DefaultErrorTypes.UNEXPECTED_ERROR }],
};
}
});

resolver.define('features', (req): ResolverResponse<FeaturesList> => {
Expand All @@ -160,4 +177,21 @@ resolver.define('appId', (): ResolverResponse<string> => {

resolver.define('getAllCompassComponentTypes', getAllComponentTypes);

resolver.define('project/createMRWithCompassYML', async (req): Promise<ResolverResponse> => {
const { project, componentId, groupId } = req.payload;

try {
await createMRWithCompassYML(project, componentId, groupId);

return {
success: true,
};
} catch (e) {
return {
success: false,
errors: [{ message: e.message }],
};
}
});

export default resolver.getDefinitions();
5 changes: 4 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppRouter } from './AppRouter';
import { AppContextProvider } from './context/AppContext';
import { ImportContextProvider } from './context/ImportContext';
import { ComponentTypesContextProvider } from './context/ComponentTypesContext';
import { ImportAllCaCProvider } from './context/ImportAllCaCContext';

export const App = () => {
const enableTheme = async () => {
Expand All @@ -20,7 +21,9 @@ export const App = () => {
<AppContextProvider>
<ComponentTypesContextProvider>
<ImportContextProvider>
<AppRouter />
<ImportAllCaCProvider>
<AppRouter />
</ImportAllCaCProvider>
</ImportContextProvider>
</ComponentTypesContextProvider>
</AppContextProvider>
Expand Down
24 changes: 24 additions & 0 deletions ui/src/components/ConnectedPage/ImportControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { router } from '@forge/bridge';

import { getCallBridge } from '@forge/bridge/out/bridge';
import { useNavigate } from 'react-router-dom';
import { Checkbox } from '@atlaskit/checkbox';
import { ImportProgressBar } from '../ImportProgressBar';
import { useImportContext } from '../../hooks/useImportContext';
import { getLastSyncTime } from '../../services/invokes';
Expand All @@ -15,13 +16,15 @@ import { ImportButtonWrapper, LastSyncTimeWrapper, StartImportButtonWrapper } fr
import { useAppContext } from '../../hooks/useAppContext';
import { Separator } from '../TooltipGenerator/styles';
import { ApplicationState } from '../../routes';
import { useImportAllCaCContext } from '../../hooks/useImportAllCaCContext';

export const ImportControls = () => {
const [lastSyncTime, setLastSyncTime] = useState<string | null>(null);
const [lastSyncTimeIsLoading, setLastSyncTimeIsLoading] = useState<boolean>(false);
const [lastSyncTimeErrorMessage, setLastSyncTimeAnErrorMessage] = useState<string>();

const { isImportInProgress } = useImportContext();
const { isCaCEnabledForImportAll, setCaCEnabledForImportAll } = useImportAllCaCContext();
const { appId, features } = useAppContext();
const navigate = useNavigate();

Expand All @@ -41,6 +44,18 @@ export const ImportControls = () => {
navigate(`${ApplicationState.CONNECTED}/import-all`, { replace: true });
};

const handleSetupImportAllCaC = async (value: boolean) => {
setCaCEnabledForImportAll(value);

const actionSubject = 'importAllSetupCaC';
const action = value ? 'enabled' : 'disabled';

await getCallBridge()('fireForgeAnalytic', {
forgeAppId: appId,
analyticEvent: `${actionSubject} ${action}`,
});
};

const fetchLastSyncTime = async () => {
setLastSyncTimeIsLoading(true);

Expand Down Expand Up @@ -89,6 +104,15 @@ export const ImportControls = () => {
Import all repositories
</Button>
</ImportButtonWrapper>
<Checkbox
isChecked={isCaCEnabledForImportAll}
label='Set up configuration file'
onChange={async () => {
await handleSetupImportAllCaC(!isCaCEnabledForImportAll);
}}
name='setup-config-file'
testId='connected-page.setup-config-file'
/>
</StartImportButtonWrapper>
)}
{features.isImportAllEnabled && <Separator />}
Expand Down
11 changes: 10 additions & 1 deletion ui/src/components/ImportAll/ProgressScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ImportComponentStateWrapper,
RepoName,
} from './styled';
import { mapStateToColor, mapStateToText } from './utils';
import { mapStateToColor, mapStateToText, mapPRCreationStateToText, mapPRCreationStateToColor } from './utils';
import { useAppContext } from '../../hooks/useAppContext';
import { IMPORT_STATE, useImportAll } from '../../hooks/useImportAll';
import { CenterWrapper } from '../styles';
Expand Down Expand Up @@ -128,6 +128,15 @@ export const ProgressScreen = ({
>
{mapStateToText(projectWithStatus.state)}
</Text>
{projectWithStatus.createPRState && (
<Text
as='strong'
color={mapPRCreationStateToColor(projectWithStatus.createPRState)}
data-testId={`import-all.progress-screen.pr-status.${projectWithStatus.name}.${projectWithStatus.createPRState}`}
>
{mapPRCreationStateToText(projectWithStatus.createPRState)}
</Text>
)}
</Flex>
);
})}
Expand Down
24 changes: 23 additions & 1 deletion ui/src/components/ImportAll/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IMPORT_STATE } from '../../hooks/useImportAll';
import { CREATE_PR_STATE, IMPORT_STATE } from '../../hooks/useImportAll';

export const mapStateToColor = (importState?: IMPORT_STATE) => {
switch (importState) {
Expand All @@ -25,3 +25,25 @@ export const mapStateToText = (importState?: IMPORT_STATE) => {
return 'Successfully imported';
}
};

export const mapPRCreationStateToColor = (prCreationState?: CREATE_PR_STATE) => {
switch (prCreationState) {
case CREATE_PR_STATE.SUCCESS:
return 'color.text.success';
case CREATE_PR_STATE.FAILED:
return 'color.text.danger';
default:
return 'color.text.success';
}
};

export const mapPRCreationStateToText = (prCreationState?: CREATE_PR_STATE) => {
switch (prCreationState) {
case CREATE_PR_STATE.SUCCESS:
return 'Pull request successfully created';
case CREATE_PR_STATE.FAILED:
return 'Pull request creation failed';
default:
return 'Pull request successfully created';
}
};
25 changes: 25 additions & 0 deletions ui/src/context/ImportAllCaCContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createContext, FunctionComponent, ReactNode, useMemo, useState } from 'react';

type ImportAllCaCProviderProps = {
children: ReactNode;
};

export type ImportAllCaCContextType = {
isCaCEnabledForImportAll: boolean;
setCaCEnabledForImportAll: (value: boolean) => void;
};

export const ImportAllCaCContext = createContext({} as ImportAllCaCContextType);

export const ImportAllCaCProvider: FunctionComponent<ImportAllCaCProviderProps> = ({ children }) => {
const [isCaCEnabledForImportAll, setCaCEnabledForImportAll] = useState(false);

const providerValues = useMemo(() => {
return {
isCaCEnabledForImportAll,
setCaCEnabledForImportAll,
};
}, [isCaCEnabledForImportAll, setCaCEnabledForImportAll]);

return <ImportAllCaCContext.Provider value={providerValues}>{children}</ImportAllCaCContext.Provider>;
};
22 changes: 22 additions & 0 deletions ui/src/context/__tests__/AppContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
webhookSetupInProgressMocks,
} from '../__mocks__/mocks';
import { defaultMocks, mockInvoke, mockGetContext } from '../../helpers/mockHelpers';
import { ImportAllCaCProvider } from '../ImportAllCaCContext';

const MOCK_APP_ID = 'app-id';

Expand Down Expand Up @@ -152,6 +153,27 @@ describe('AppContext', () => {
});
});

it('setup config-as-code checkbox sends analytic event', async () => {
mockInvoke(mockWithEnablindImportAllFF);
mockGetContext('admin-page-ui');

const { findByTestId } = render(
<AppContextProvider>
<ImportAllCaCProvider>
<AppRouter />
</ImportAllCaCProvider>
</AppContextProvider>,
);

const setupCaCWithImportAll = await findByTestId('connected-page.setup-config-file--checkbox-label');

setupCaCWithImportAll?.click();
expectToSendAnalyticsEvent('importAllSetupCaC enabled');

setupCaCWithImportAll?.click();
expectToSendAnalyticsEvent('importAllSetupCaC disabled');
});

it('renders connected page without import all button, if FF_IMPORT_ALL_ENABLED ff is disabled', () => {
mockInvoke(filledMocks);
mockGetContext('admin-page-ui');
Expand Down
47 changes: 38 additions & 9 deletions ui/src/hooks/useImportAll.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { showFlag } from '@forge/bridge';
import { useAppContext } from './useAppContext';
import { getGroupProjects, importProjects } from '../services/invokes';
import { createMRWithCompassYML, createSingleComponent, getGroupProjects, importProjects } from '../services/invokes';
import { getComponentTypeOptionForBuiltInType, sleep } from '../components/utils';
import { ImportableProject } from '../types';
import { DEFAULT_COMPONENT_TYPE_ID } from '../constants';
import { useComponentTypes } from './useComponentTypes';
import { useImportAllCaCContext } from './useImportAllCaCContext';

const DELAY_BETWEEN_REPO_IMPORT_CALLS = 50;

Expand All @@ -18,8 +19,14 @@ export enum IMPORT_STATE {
ALREADY_IMPORTED = 'ALREADY_IMPORTED',
}

export enum CREATE_PR_STATE {
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
}

export type ImportProjectWithStates = ImportableProject & {
state?: IMPORT_STATE;
createPRState?: CREATE_PR_STATE;
};

export const useImportAll = (): {
Expand All @@ -34,8 +41,12 @@ export const useImportAll = (): {
const [projectsFetchingError, setProjectsFetchingError] = useState<string>('');
const { componentTypes } = useComponentTypes();
const { getConnectedInfo } = useAppContext();
const { isCaCEnabledForImportAll } = useImportAllCaCContext();

const updateProjectsToImport = (repository: ImportProjectWithStates, states: { state: IMPORT_STATE }) => {
const updateProjectsToImport = (
repository: ImportProjectWithStates,
states: { state: IMPORT_STATE; createPRState?: CREATE_PR_STATE },
) => {
setImportedProjects((prevState) => [...prevState, { ...repository, ...states }]);
};

Expand All @@ -47,16 +58,34 @@ export const useImportAll = (): {
});
} else {
try {
const importResponse = await importProjects([repositoryToImport], groupId);

if (!importResponse.success && !importResponse.data) {
const importResponse = await createSingleComponent(repositoryToImport);

if (isCaCEnabledForImportAll) {
if (importResponse.success && importResponse.data) {
try {
await createMRWithCompassYML(repositoryToImport, importResponse.data.id, groupId);

updateProjectsToImport(repositoryToImport, {
state: IMPORT_STATE.SUCCESS,
createPRState: CREATE_PR_STATE.SUCCESS,
});
} catch (e) {
updateProjectsToImport(repositoryToImport, {
state: IMPORT_STATE.SUCCESS,
createPRState: CREATE_PR_STATE.FAILED,
});
}
}
} else {
if (!importResponse.success && !importResponse.data) {
updateProjectsToImport(repositoryToImport, {
state: IMPORT_STATE.FAILED,
});
}
updateProjectsToImport(repositoryToImport, {
state: IMPORT_STATE.FAILED,
state: IMPORT_STATE.SUCCESS,
});
}
updateProjectsToImport(repositoryToImport, {
state: IMPORT_STATE.SUCCESS,
});
} catch (e) {
updateProjectsToImport(repositoryToImport, {
state: IMPORT_STATE.FAILED,
Expand Down
4 changes: 4 additions & 0 deletions ui/src/hooks/useImportAllCaCContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { useContext } from 'react';
import { ImportAllCaCContext, ImportAllCaCContextType } from '../context/ImportAllCaCContext';

export const useImportAllCaCContext = (): ImportAllCaCContextType => useContext(ImportAllCaCContext);
20 changes: 19 additions & 1 deletion ui/src/services/invokes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { invoke } from '@forge/bridge';
import { CompassComponentTypeObject } from '@atlassian/forge-graphql';
import { CompassComponentTypeObject, Component } from '@atlassian/forge-graphql';

import {
ImportableProject,
Expand Down Expand Up @@ -125,3 +125,21 @@ export const getTeamOnboarding = (): Promise<ResolverResponse<{ isTeamOnboarding
export const setTeamOnboarding = (): Promise<ResolverResponse> => {
return invoke<ResolverResponse>('onboarding/team/set');
};

export const createSingleComponent = (projectToImport: ImportableProject): Promise<ResolverResponse<Component>> => {
return invoke<ResolverResponse<Component>>('createSingleComponent', {
projectToImport,
});
};

export const createMRWithCompassYML = (
project: ImportableProject,
componentId: string,
groupId: number,
): Promise<ResolverResponse> => {
return invoke<ResolverResponse>('project/createMRWithCompassYML', {
project,
componentId,
groupId,
});
};

0 comments on commit d47b0c6

Please sign in to comment.