Skip to content

Commit

Permalink
add teams client, invoke and resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
apeni committed Jan 22, 2024
1 parent 8e97578 commit 8db1208
Show file tree
Hide file tree
Showing 13 changed files with 445 additions and 4 deletions.
1 change: 1 addition & 0 deletions manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ permissions:
- write:scorecard:compass
- write:metric:compass
- read:metric:compass
- view:team:teams
external:
fetch:
client:
Expand Down
10 changes: 10 additions & 0 deletions src/client/agg-response-error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DEFAULT_SERVER_ERROR_MESSAGE } from '../models/error-messages';

export const aggResponseErrorHandler = (responseBody: any) => {
const { data, errors } = responseBody;
if (errors) {
console.error('AGG error returned', errors);
throw new Error(DEFAULT_SERVER_ERROR_MESSAGE);
}
return data;
};
15 changes: 15 additions & 0 deletions src/client/agg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import api from '@forge/api';
import { aggResponseErrorHandler } from './agg-response-error-handler';
import { AggOperation } from '../types';

export const aggQuery = async (request: AggOperation) => {
const response = await api.asApp().requestGraph(request.query, request.variables);
const responseBody = await response.json();
console.log({
message: 'AGG request',
requestName: request.name,
responseStatus: response.status,
requestId: responseBody?.extensions?.gateway?.request_id,
});
return aggResponseErrorHandler(responseBody);
};
23 changes: 22 additions & 1 deletion src/client/compass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import graphqlGateway, {
GetComponentInput,
UnLinkComponentInput,
} from '@atlassian/forge-graphql';
import { ImportableProject, COMPASS_GATEWAY_MESSAGES, Metric } from '../types';
import { ImportableProject, COMPASS_GATEWAY_MESSAGES, Metric, Team } from '../types';
import { EXTERNAL_SOURCE, IMPORT_LABEL } from '../constants';
import { UNKNOWN_EXTERNAL_ALIAS_ERROR_MESSAGE } from '../models/error-messages';
import { AggClientError, GraphqlGatewayError } from '../models/errors';
import { getTenantContextQuery } from './get-tenat-context-query';
import { aggQuery } from './agg';
import { getTeamsQuery } from './get-teams-query';

const throwIfErrors = function throwIfSdkErrors(method: string, errors: SdkError[]) {
// Checking if any invalid config errors to report.
Expand Down Expand Up @@ -165,3 +168,21 @@ export const getAllComponentTypeIds = async (cloudId: string): Promise<CompassCo
throwIfErrors('getAllComponentTypeIds', errors);
return data.componentTypes;
};

export const getTenantContext = async (cloudId: string) => {
const tenantContextQuery = getTenantContextQuery(cloudId);
const data = await aggQuery(tenantContextQuery);
return data;
};

export const getTeams = async (
orgId: string,
cloudId: string,
accountId?: string,
searchValue?: string,
): Promise<Team[]> => {
const organizationId = `ari:cloud:platform::org/${orgId}`;
const teamsQuery = getTeamsQuery(organizationId, cloudId, accountId, searchValue);
const data = await aggQuery(teamsQuery);
return data.team.teamSearchV2.nodes;
};
46 changes: 46 additions & 0 deletions src/client/get-teams-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { TEAMS_AMOUNT } from '../constants';

export function getTeamsQuery(organizationId: string, siteId: string, accountId?: string, searchValue?: string) {
const memberIds = accountId ? [accountId] : [];
const query = searchValue ?? '';

const sortBy = [
{
field: 'DISPLAY_NAME',
order: 'ASC',
},
{
field: 'STATE',
order: 'ASC',
},
];

return {
name: 'teams',
query: `
query teamSearchV2 ($organizationId: ID!, $siteId: String!, $memberIds: [ID] = [], $sortBy: [TeamSort], $first: Int, $query: String!) {
team {
teamSearchV2 (organizationId: $organizationId, siteId: $siteId, filter: { query: $query, membership: {memberIds: $memberIds}}, sortBy: $sortBy, first: $first) @optIn(to: "Team-search-v2") {
nodes {
team {
id
displayName
smallAvatarImageUrl
state
}
}
}
}
}
`,
variables: {
organizationId,
siteId,
sortBy,
memberIds,
query,
first: TEAMS_AMOUNT,
},
};
}
15 changes: 15 additions & 0 deletions src/client/get-tenat-context-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function getTenantContextQuery(cloudId: string) {
return {
name: 'tenantContext',
query: `
query tenantContext($cloudId: ID!) {
tenantContexts(cloudIds: [$cloudId]) {
orgId
}
}
`,
variables: {
cloudId,
},
};
}
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ This PR is automatically generated by the integration of Compass with GitLab.
`;
export const COMMIT_MESSAGE = 'Compass.yml file for config-as-code';
export const DEFAULT_CONFIG_VERSION = 1;
export const TEAMS_AMOUNT = 30;
115 changes: 115 additions & 0 deletions src/resolvers/import-resolvers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* eslint-disable import/first */
class MockResolver {
resolvers: { [key: string]: any };

constructor() {
this.resolvers = {};
}

define = (name: any, fn: any) => {
this.resolvers[name] = fn;
};

getDefinitions = () => this.resolvers;
}
jest.mock('@forge/resolver', () => MockResolver);

import handler from './import-resolvers';
import * as featureFlags from '../services/feature-flags';
import * as getTeams from '../services/get-teams';
import { MOCK_CLOUD_ID } from '../__tests__/fixtures/gitlab-data';

describe('importResolvers', () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe('getFirstPageOfTeamsWithMembershipStatus', () => {
const { getFirstPageOfTeamsWithMembershipStatus } = handler as any;

test('successfully returns teams data', async () => {
const mockTeam = {
teamId: 'teamId',
displayName: 'displayName',
imageUrl: 'https://test',
};
const mockTeamsResponse = { teamsWithMembership: [mockTeam], otherTeams: [mockTeam] };

jest
.spyOn(featureFlags, 'listFeatures')
.mockReturnValueOnce({ isOwnerTeamEnabled: true, isSendStagingEventsEnabled: false });
jest.spyOn(getTeams, 'getFirstPageOfTeamsWithMembershipStatus').mockResolvedValueOnce(mockTeamsResponse);

const teamsResponse = await getFirstPageOfTeamsWithMembershipStatus({
context: {
cloudId: MOCK_CLOUD_ID,
accountId: 'test-account-id',
},
payload: {
searchTeamValue: 'searchTeamValue',
},
});

expect(teamsResponse).toEqual({
success: true,
data: {
teams: mockTeamsResponse,
},
});
});

test('returns an error if getFirstPageOfTeamsWithMembershipStatus request failed', async () => {
const mockError = new Error('error');
jest
.spyOn(featureFlags, 'listFeatures')
.mockReturnValueOnce({ isOwnerTeamEnabled: true, isSendStagingEventsEnabled: false });
jest.spyOn(getTeams, 'getFirstPageOfTeamsWithMembershipStatus').mockRejectedValueOnce(mockError);

const teamsResponse = await getFirstPageOfTeamsWithMembershipStatus({
context: {
cloudId: MOCK_CLOUD_ID,
accountId: 'test-account-id',
},
payload: {
searchTeamValue: 'searchTeamValue',
},
});

expect(teamsResponse).toEqual({
success: false,
errors: [{ message: mockError.message }],
});
});

test('returns empty teams data if the isOwnerTeamEnabled disabled', async () => {
const mockTeam = {
teamId: 'teamId',
displayName: 'displayName',
imageUrl: 'https://test',
};
const mockTeamsResponse = { teamsWithMembership: [mockTeam], otherTeams: [mockTeam] };

jest
.spyOn(featureFlags, 'listFeatures')
.mockReturnValueOnce({ isOwnerTeamEnabled: false, isSendStagingEventsEnabled: false });
jest.spyOn(getTeams, 'getFirstPageOfTeamsWithMembershipStatus').mockResolvedValueOnce(mockTeamsResponse);

const teamsResponse = await getFirstPageOfTeamsWithMembershipStatus({
context: {
cloudId: MOCK_CLOUD_ID,
accountId: 'test-account-id',
},
payload: {
searchTeamValue: 'searchTeamValue',
},
});

expect(teamsResponse).toEqual({
success: true,
data: {
teams: { teamsWithMembership: [], otherTeams: [] },
},
});
});
});
});
27 changes: 24 additions & 3 deletions src/resolvers/import-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
ProjectImportResult,
ImportStatus,
FeaturesList,
DefaultErrorTypes,
} from '../resolverTypes';
import {
clearImportResult,
Expand All @@ -18,10 +17,11 @@ import {
ImportFailedError,
importProjects,
} from '../services/import-projects';
import { GroupProjectsResponse } from '../types';
import { GroupProjectsResponse, TeamsWithMembershipStatus } from '../types';
import { getAllComponentTypeIds } from '../client/compass';
import { appId, connectedGroupsInfo, getFeatures, groupsAllExisting } from './shared-resolvers';
import { getForgeAppId } from '../utils/get-forge-app-id';
import { listFeatures } from '../services/feature-flags';
import { getFirstPageOfTeamsWithMembershipStatus } from '../services/get-teams';

const resolver = new Resolver();

Expand Down Expand Up @@ -142,4 +142,25 @@ resolver.define('getAllCompassComponentTypes', async (req): Promise<ResolverResp
}
});

resolver.define(
'getFirstPageOfTeamsWithMembershipStatus',
async (req): Promise<ResolverResponse<{ teams: TeamsWithMembershipStatus }>> => {
const { cloudId, accountId } = req.context;
const { searchTeamValue } = req.payload;
const { isOwnerTeamEnabled } = listFeatures();
if (!isOwnerTeamEnabled) {
return { success: true, data: { teams: { teamsWithMembership: [], otherTeams: [] } } };
}
try {
const teams = await getFirstPageOfTeamsWithMembershipStatus(cloudId, accountId, searchTeamValue);
return { success: true, data: { teams } };
} catch (e) {
return {
success: false,
errors: [{ message: e.message }],
};
}
},
);

export default resolver.getDefinitions();
94 changes: 94 additions & 0 deletions src/services/get-teams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mocked } from 'ts-jest/utils';

import { getFirstPageOfTeamsWithMembershipStatus } from './get-teams';
import { getTeams, getTenantContext } from '../client/compass';
import { MappedTeam } from '../types';
import { MOCK_CLOUD_ID } from '../__tests__/fixtures/gitlab-data';

jest.mock('../client/compass');

const mockGetTenantContext = mocked(getTenantContext);
const mockGetTeams = mocked(getTeams);

const MOCK_GET_TENANT_CONTEXT = {
tenantContexts: [{ orgId: 'orgId' }],
};
const TEAM_ID = 'team_id';

const mockGetFirstPageOfTeamsWithMembershipStatus = (id: string, teamsAmount = 1) => {
const team = {
team: {
id,
displayName: 'team_name',
smallAvatarImageUrl: 'imageUrl',
state: 'ACTIVE',
},
};

return new Array(teamsAmount).fill(team);
};

const mockMappedTeams = (teamId: string, teamsAmount = 1) => {
const mappedTeam: MappedTeam = {
teamId,
displayName: 'team_name',
imageUrl: 'imageUrl',
};

return new Array(teamsAmount).fill(mappedTeam);
};

describe('Get teams', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('returns just teams with membership in case there are 30 of them', async () => {
mockGetTenantContext.mockResolvedValue(MOCK_GET_TENANT_CONTEXT);
mockGetTeams.mockResolvedValueOnce(mockGetFirstPageOfTeamsWithMembershipStatus(TEAM_ID, 30));

const expectedResult = mockMappedTeams(TEAM_ID, 30);

const result = await getFirstPageOfTeamsWithMembershipStatus(MOCK_CLOUD_ID);

expect(result).toEqual({ teamsWithMembership: expectedResult, otherTeams: [] });
});

test('returns just other teams in case there are no teams with membership', async () => {
mockGetTenantContext.mockResolvedValue(MOCK_GET_TENANT_CONTEXT);
mockGetTeams.mockResolvedValueOnce([]);
mockGetTeams.mockResolvedValueOnce(mockGetFirstPageOfTeamsWithMembershipStatus(TEAM_ID, 1));

const expectedResult = mockMappedTeams(TEAM_ID, 1);

const result = await getFirstPageOfTeamsWithMembershipStatus(MOCK_CLOUD_ID);

expect(result).toEqual({ teamsWithMembership: [], otherTeams: expectedResult });
});

test('returns all teams', async () => {
mockGetTenantContext.mockResolvedValue(MOCK_GET_TENANT_CONTEXT);
mockGetTeams.mockResolvedValueOnce(mockGetFirstPageOfTeamsWithMembershipStatus(TEAM_ID, 2));
mockGetTeams.mockResolvedValueOnce([
...mockGetFirstPageOfTeamsWithMembershipStatus(TEAM_ID, 2),
...mockGetFirstPageOfTeamsWithMembershipStatus('team_id_2', 2),
]);

const expectedResult = {
teamsWithMembership: mockMappedTeams(TEAM_ID, 2),
otherTeams: mockMappedTeams('team_id_2', 2),
};

const result = await getFirstPageOfTeamsWithMembershipStatus(MOCK_CLOUD_ID);

expect(result).toEqual(expectedResult);
});

test('throws an error in case of error while getting tenant context', async () => {
mockGetTenantContext.mockRejectedValue(new Error('Error'));

const errorMessage = 'Error while getting teams.';

await expect(getFirstPageOfTeamsWithMembershipStatus(MOCK_CLOUD_ID)).rejects.toThrow(new Error(errorMessage));
});
});
Loading

0 comments on commit 8db1208

Please sign in to comment.