Skip to content

Commit 92b85c7

Browse files
authored
Create GitHub teams in the background of Org requests (#338)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Organization GitHub team creation now runs asynchronously via a queued background flow (batched dispatch and worker processing). * Team creation responses now indicate whether a team was newly created or already existed (returns id plus updated flag). * **Chores** * Updated lock/Redis utility dependency to a newer patch version. * **Tests** * Added comprehensive unit tests for the new queued team-creation handler and updated tests for the changed return shape. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fa72249 commit 92b85c7

File tree

11 files changed

+593
-126
lines changed

11 files changed

+593
-126
lines changed

src/api/functions/github.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export async function createGithubTeam({
152152

153153
if (existingTeam) {
154154
logger.info(`Team "${name}" already exists with id: ${existingTeam.id}`);
155-
return existingTeam.id;
155+
return { updated: false, id: existingTeam.id };
156156
}
157157
logger.info(`Creating GitHub team "${name}"`);
158158
const response = await octokit.request("POST /orgs/{org}/teams", {
@@ -196,7 +196,7 @@ export async function createGithubTeam({
196196
logger.warn(`Failed to remove user from team ${newTeamId}:`, removeError);
197197
}
198198

199-
return newTeamId;
199+
return { updated: true, id: newTeamId };
200200
} catch (e) {
201201
if (e instanceof BaseError) {
202202
throw e;

src/api/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"pino": "^9.6.0",
5858
"pluralize": "^8.0.0",
5959
"qrcode": "^1.5.4",
60-
"redlock-universal": "^0.6.0",
60+
"redlock-universal": "^0.6.4",
6161
"sanitize-html": "^2.17.0",
6262
"stripe": "^18.0.0",
6363
"uuid": "^11.1.0",
@@ -74,4 +74,4 @@
7474
"pino-pretty": "^13.1.1",
7575
"yaml": "^2.8.1"
7676
}
77-
}
77+
}

src/api/routes/organizations.ts

Lines changed: 27 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,15 @@ import {
5353
} from "api/functions/entraId.js";
5454
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
5555
import { getRoleCredentials } from "api/functions/sts.js";
56-
import { SQSClient } from "@aws-sdk/client-sqs";
56+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
5757
import { sendSqsMessagesInBatches } from "api/functions/sqs.js";
5858
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
5959
import {
6060
assignIdpGroupsToTeam,
6161
createGithubTeam,
6262
} from "api/functions/github.js";
6363
import { SKIP_EXTERNAL_ORG_LEAD_UPDATE } from "common/overrides.js";
64+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
6465

6566
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`;
6667

@@ -413,12 +414,10 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
413414
? (unmarshall(metadataResponse.Item).leadsEntraGroupId as string)
414415
: undefined;
415416

416-
let githubTeamId = metadataResponse.Item
417-
? (unmarshall(metadataResponse.Item).githubTeamId as number)
417+
const githubTeamId = metadataResponse.Item
418+
? (unmarshall(metadataResponse.Item).leadsGithubTeamId as number)
418419
: undefined;
419420

420-
let createdGithubTeam = false;
421-
422421
const entraIdToken = await getEntraIdToken({
423422
clients,
424423
clientId: fastify.environmentConfig.AadValidClientId,
@@ -428,8 +427,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
428427

429428
const shouldCreateNewEntraGroup =
430429
!entraGroupId && !shouldSkipEnhancedActions;
431-
const shouldCreateNewGithubGroup =
432-
!githubTeamId && !shouldSkipEnhancedActions;
433430
const grpDisplayName = `${request.params.orgId} Admin`;
434431
const orgInfo = getOrgByName(request.params.orgId);
435432
if (!orgInfo) {
@@ -524,65 +521,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
524521
}
525522
}
526523

527-
// Create GitHub team if needed
528-
if (shouldCreateNewGithubGroup) {
529-
request.log.info(
530-
`No GitHub team exists for ${request.params.orgId}. Creating new team...`,
531-
);
532-
const suffix = fastify.environmentConfig.GroupEmailSuffix;
533-
githubTeamId = await createGithubTeam({
534-
orgId: fastify.environmentConfig.GithubOrgName,
535-
githubToken: fastify.secretConfig.github_pat,
536-
parentTeamId: fastify.environmentConfig.ExecGithubTeam,
537-
name: `${grpShortName}${suffix === "" ? "" : `-${suffix}`}`,
538-
description: grpDisplayName,
539-
logger: request.log,
540-
});
541-
request.log.info(
542-
`Created GitHub team "${githubTeamId}" for ${request.params.orgId} leads.`,
543-
);
544-
createdGithubTeam = true;
545-
546-
// Store GitHub team ID immediately
547-
const logStatement = buildAuditLogTransactPut({
548-
entry: {
549-
module: Modules.ORG_INFO,
550-
message: `Created GitHub team "${githubTeamId}" for organization leads.`,
551-
actor: request.username!,
552-
target: request.params.orgId,
553-
},
554-
});
555-
556-
const storeGithubIdOperation = async () => {
557-
const commandTransaction = new TransactWriteItemsCommand({
558-
TransactItems: [
559-
...(logStatement ? [logStatement] : []),
560-
{
561-
Update: {
562-
TableName: genericConfig.SigInfoTableName,
563-
Key: marshall({
564-
primaryKey: `DEFINE#${request.params.orgId}`,
565-
entryId: "0",
566-
}),
567-
UpdateExpression:
568-
"SET leadsGithubTeamId = :githubTeamId, updatedAt = :updatedAt",
569-
ExpressionAttributeValues: marshall({
570-
":githubTeamId": githubTeamId,
571-
":updatedAt": new Date().toISOString(),
572-
}),
573-
},
574-
},
575-
],
576-
});
577-
return await clients.dynamoClient.send(commandTransaction);
578-
};
579-
580-
await retryDynamoTransactionWithBackoff(
581-
storeGithubIdOperation,
582-
request.log,
583-
`Store GitHub team ID for ${request.params.orgId}`,
584-
);
585-
}
586524
const commonArgs = {
587525
orgId: request.params.orgId,
588526
actorUsername: request.username!,
@@ -628,36 +566,37 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
628566
.map((r) => r.value)
629567
.filter((p): p is SQSMessage => p !== null);
630568

569+
if (!fastify.sqsClient) {
570+
fastify.sqsClient = new SQSClient({
571+
region: genericConfig.AwsRegion,
572+
});
573+
}
574+
575+
// Queue creating GitHub team if needed
576+
if (!githubTeamId) {
577+
const sqsPayload: SQSPayload<AvailableSQSFunctions.CreateOrgGithubTeam> =
578+
{
579+
function: AvailableSQSFunctions.CreateOrgGithubTeam,
580+
metadata: {
581+
initiator: request.username!,
582+
reqId: request.id,
583+
},
584+
payload: {
585+
orgName: request.params.orgId,
586+
githubTeamDescription: grpDisplayName,
587+
githubTeamName: grpShortName,
588+
},
589+
};
590+
sqsPayloads.push(sqsPayload);
591+
}
631592
if (sqsPayloads.length > 0) {
632-
if (!fastify.sqsClient) {
633-
fastify.sqsClient = new SQSClient({
634-
region: genericConfig.AwsRegion,
635-
});
636-
}
637593
await sendSqsMessagesInBatches({
638594
sqsClient: fastify.sqsClient,
639595
queueUrl: fastify.environmentConfig.SqsQueueUrl,
640596
logger: request.log,
641597
sqsPayloads,
642598
});
643599
}
644-
645-
if (
646-
createdGithubTeam &&
647-
githubTeamId &&
648-
fastify.environmentConfig.GithubIdpSyncEnabled
649-
) {
650-
request.log.info("Setting up IDP sync for Github team!");
651-
await assignIdpGroupsToTeam({
652-
githubToken: fastify.secretConfig.github_pat,
653-
teamId: githubTeamId,
654-
logger: request.log,
655-
groupsToSync: [entraGroupId].filter((x): x is string => !!x),
656-
orgId: fastify.environmentConfig.GithubOrgId,
657-
orgName: fastify.environmentConfig.GithubOrgName,
658-
});
659-
}
660-
661600
return reply.status(201).send();
662601
},
663602
);
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { AvailableSQSFunctions } from "common/types/sqsMessage.js";
2+
import { currentEnvironmentConfig, SQSHandlerFunction } from "../index.js";
3+
import {
4+
DynamoDBClient,
5+
GetItemCommand,
6+
TransactWriteItemsCommand,
7+
} from "@aws-sdk/client-dynamodb";
8+
import { genericConfig, SecretConfig } from "common/config.js";
9+
import { getSecretConfig } from "../utils.js";
10+
import RedisModule from "ioredis";
11+
import { createLock, IoredisAdapter } from "redlock-universal";
12+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
13+
import { InternalServerError } from "common/errors/index.js";
14+
import {
15+
assignIdpGroupsToTeam,
16+
createGithubTeam,
17+
} from "api/functions/github.js";
18+
import { buildAuditLogTransactPut } from "api/functions/auditLog.js";
19+
import { Modules } from "common/modules.js";
20+
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
21+
import { SKIP_EXTERNAL_ORG_LEAD_UPDATE } from "common/overrides.js";
22+
import { getOrgByName } from "@acm-uiuc/js-shared";
23+
24+
export const createOrgGithubTeamHandler: SQSHandlerFunction<
25+
AvailableSQSFunctions.CreateOrgGithubTeam
26+
> = async (payload, metadata, logger) => {
27+
const secretConfig: SecretConfig = await getSecretConfig({
28+
logger,
29+
commonConfig: { region: genericConfig.AwsRegion },
30+
});
31+
const redisClient = new RedisModule.default(secretConfig.redis_url);
32+
try {
33+
const { orgName, githubTeamName, githubTeamDescription } = payload;
34+
const orgImmutableId = getOrgByName(orgName)!.id;
35+
if (SKIP_EXTERNAL_ORG_LEAD_UPDATE.includes(orgImmutableId)) {
36+
logger.info(
37+
`Organization ${orgName} has external updates disabled, exiting.`,
38+
);
39+
return;
40+
}
41+
const dynamo = new DynamoDBClient({
42+
region: genericConfig.AwsRegion,
43+
});
44+
const lock = createLock({
45+
adapter: new IoredisAdapter(redisClient),
46+
key: `createOrgGithubTeamHandler:${orgImmutableId}`,
47+
retryAttempts: 5,
48+
retryDelay: 300,
49+
});
50+
return await lock.using(async (signal) => {
51+
const getMetadataCommand = new GetItemCommand({
52+
TableName: genericConfig.SigInfoTableName,
53+
Key: marshall({
54+
primaryKey: `DEFINE#${orgName}`,
55+
entryId: "0",
56+
}),
57+
ProjectionExpression: "#entra,#gh",
58+
ExpressionAttributeNames: {
59+
"#entra": "leadsEntraGroupId",
60+
"#gh": "leadsGithubTeamId",
61+
},
62+
ConsistentRead: true,
63+
});
64+
const existingData = await dynamo.send(getMetadataCommand);
65+
if (!existingData || !existingData.Item) {
66+
throw new InternalServerError({
67+
message: `Could not find org entry for ${orgName}`,
68+
});
69+
}
70+
const currentOrgInfo = unmarshall(existingData.Item) as {
71+
leadsEntraGroupId?: string;
72+
leadsGithubTeamId?: string;
73+
};
74+
if (!currentOrgInfo.leadsEntraGroupId) {
75+
logger.info(`${orgName} does not have an Entra group, skipping!`);
76+
return;
77+
}
78+
if (currentOrgInfo.leadsGithubTeamId) {
79+
logger.info("This org already has a GitHub team, skipping");
80+
return;
81+
}
82+
if (signal.aborted) {
83+
throw new InternalServerError({
84+
message:
85+
"Checked on lock before creating GitHub team, we've lost the lock!",
86+
});
87+
}
88+
logger.info(`Creating GitHub team for ${orgName}!`);
89+
const suffix = currentEnvironmentConfig.GroupEmailSuffix;
90+
const finalName = `${githubTeamName}${suffix === "" ? "" : `-${suffix}`}`;
91+
const { updated, id: teamId } = await createGithubTeam({
92+
orgId: currentEnvironmentConfig.GithubOrgName,
93+
githubToken: secretConfig.github_pat,
94+
parentTeamId: currentEnvironmentConfig.ExecGithubTeam,
95+
name: finalName,
96+
description: githubTeamDescription,
97+
logger,
98+
});
99+
if (!updated) {
100+
logger.info(
101+
`Github team "${finalName}" already existed. We're assuming team sync was already set up (if not, please configure manually).`,
102+
);
103+
} else {
104+
logger.info(
105+
`Github team "${finalName}" created with team ID "${teamId}".`,
106+
);
107+
if (currentEnvironmentConfig.GithubIdpSyncEnabled) {
108+
logger.info(
109+
`Setting up IDP sync for Github team from Entra ID group ${currentOrgInfo.leadsEntraGroupId}`,
110+
);
111+
await assignIdpGroupsToTeam({
112+
githubToken: secretConfig.github_pat,
113+
teamId,
114+
logger,
115+
groupsToSync: [currentOrgInfo.leadsEntraGroupId],
116+
orgId: currentEnvironmentConfig.GithubOrgId,
117+
orgName: currentEnvironmentConfig.GithubOrgName,
118+
});
119+
}
120+
}
121+
logger.info("Adding updates to audit log");
122+
const logStatement = updated
123+
? buildAuditLogTransactPut({
124+
entry: {
125+
module: Modules.ORG_INFO,
126+
message: `Created GitHub team "${finalName}" for organization leads.`,
127+
actor: metadata.initiator,
128+
target: orgName,
129+
},
130+
})
131+
: undefined;
132+
const storeGithubIdOperation = async () => {
133+
const commandTransaction = new TransactWriteItemsCommand({
134+
TransactItems: [
135+
...(logStatement ? [logStatement] : []),
136+
{
137+
Update: {
138+
TableName: genericConfig.SigInfoTableName,
139+
Key: marshall({
140+
primaryKey: `DEFINE#${orgName}`,
141+
entryId: "0",
142+
}),
143+
UpdateExpression:
144+
"SET leadsGithubTeamId = :githubTeamId, updatedAt = :updatedAt",
145+
ExpressionAttributeValues: marshall({
146+
":githubTeamId": teamId,
147+
":updatedAt": new Date().toISOString(),
148+
}),
149+
},
150+
},
151+
],
152+
});
153+
return await dynamo.send(commandTransaction);
154+
};
155+
156+
await retryDynamoTransactionWithBackoff(
157+
storeGithubIdOperation,
158+
logger,
159+
`Store GitHub team ID for ${orgName}`,
160+
);
161+
});
162+
} finally {
163+
try {
164+
await redisClient.quit();
165+
} catch {
166+
redisClient.disconnect();
167+
}
168+
}
169+
};

src/api/sqs/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { emailMembershipPassHandler } from "./emailMembershipPassHandler.js";
33
export { provisionNewMemberHandler } from "./provisionNewMember.js";
44
export { sendSaleEmailHandler } from "./sendSaleEmailHandler.js";
55
export { emailNotificationsHandler } from "./emailNotifications.js";
6+
export { createOrgGithubTeamHandler } from "./createOrgGithubTeam.js";

src/api/sqs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { ValidationError } from "../../common/errors/index.js";
2323
import { RunEnvironment } from "../../common/roles.js";
2424
import { environmentConfig } from "../../common/config.js";
25+
import { createOrgGithubTeamHandler } from "./handlers/createOrgGithubTeam.js";
2526

2627
export type SQSFunctionPayloadTypes = {
2728
[K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction<K>;
@@ -39,6 +40,7 @@ const handlers: SQSFunctionPayloadTypes = {
3940
[AvailableSQSFunctions.ProvisionNewMember]: provisionNewMemberHandler,
4041
[AvailableSQSFunctions.SendSaleEmail]: sendSaleEmailHandler,
4142
[AvailableSQSFunctions.EmailNotifications]: emailNotificationsHandler,
43+
[AvailableSQSFunctions.CreateOrgGithubTeam]: createOrgGithubTeamHandler,
4244
};
4345
export const runEnvironment = process.env.RunEnvironment as RunEnvironment;
4446
export const currentEnvironmentConfig = environmentConfig[runEnvironment];

0 commit comments

Comments
 (0)