Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add teams client, invoke and resolver #93

Merged
merged 1 commit into from
Jan 22, 2024
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
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
Loading