-
Notifications
You must be signed in to change notification settings - Fork 0
Seperate Exec membership from Org admin #343
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
base: main
Are you sure you want to change the base?
Conversation
WalkthroughAdds an asynchronous SQS-driven exec council sync: replaces direct dynamic lead queries with parallel per-org queries, introduces a syncExecCouncil SQS handler, renames ExecGithubTeam config to OrgAdminGithubParentTeam, and makes GitHub team parentTeamId optional. Changes
Sequence Diagram(s)sequenceDiagram
participant Route as Org Route
participant SQS as SQS
participant Handler as syncExecCouncilHandler
participant DDB as DynamoDB
participant Entra as Entra ID
Route->>SQS: Queue SyncExecCouncil message
SQS->>Handler: Dequeue & invoke
Handler->>DDB: Parallel per-org queries (ConsistentRead)
DDB-->>Handler: Voting leads array
Handler->>Entra: Fetch current group members
Entra-->>Handler: Current members list
Handler->>Handler: Compute adds / removes
Handler->>Entra: Add missing members (iterative)
Handler->>Entra: Remove non-voting members (iterative)
Handler-->>SQS: Log summary / metrics
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
💰 Infracost reportMonthly estimate generatedThis comment will be updated when code changes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/api/functions/github.ts (1)
6-15: Make parent_team_id conditional when parentTeamId is undefined.Now that parentTeamId is optional, avoid sending
parent_team_id: undefinedwhich can 4xx.- const response = await octokit.request("POST /orgs/{org}/teams", { - org: orgId, - name, - description: `[Managed by Core API]${description ? ` ${description}` : ""}`, - privacy: privacy || "closed", - notification_setting: "notifications_enabled", - parent_team_id: parentTeamId, - }); + const body: Record<string, any> = { + org: orgId, + name, + description: `[Managed by Core API]${description ? ` ${description}` : ""}`, + privacy: privacy || "closed", + notification_setting: "notifications_enabled", + }; + if (parentTeamId != null) body.parent_team_id = parentTeamId; + const response = await octokit.request("POST /orgs/{org}/teams", body);
🧹 Nitpick comments (2)
src/api/sqs/handlers/syncExecCouncil.ts (1)
1-20: import/extensions lints on “.js” paths.If ESM with NodeNext, keep “.js” and relax ESLint; otherwise drop extensions.
src/api/functions/organizations.ts (1)
588-630: Prefer GetItem (or BatchGetItem) over Query for exact PK+SK; lower latency/RCUsYou’re querying by full primary key (partition + sort). GetItem with ConsistentRead is the direct, cheaper call and simplifies the code. BatchGetItem could fetch all org keys in one request if desired.
Add import (outside this hunk):
+import { GetItemCommand } from "@aws-sdk/client-dynamodb";Refactor body:
for (const orgName of AllOrganizationNameList) { - const leadsQuery = new QueryCommand({ - TableName: genericConfig.SigInfoTableName, - KeyConditionExpression: "primaryKey = :leadName AND entryId = :username", - ExpressionAttributeValues: { - ":leadName": { S: `LEAD#${orgName}` }, - ":username": { S: username }, - }, - ConsistentRead: true, - }); - - try { - const responseMarshall = await dynamoClient.send(leadsQuery); - if (responseMarshall.Items && responseMarshall.Items.length > 0) { - const lead = unmarshall(responseMarshall.Items[0]); - // If they're a lead and not a non-voting member, they should be in exec - if (!lead.nonVotingMember) { - return true; - } - } - } catch (e) { + try { + const res = await dynamoClient.send( + new GetItemCommand({ + TableName: genericConfig.SigInfoTableName, + Key: marshall({ + primaryKey: `LEAD#${orgName}`, + entryId: username, + }), + ConsistentRead: true, + ProjectionExpression: "nonVotingMember", + }), + ); + if (res.Item) { + const lead = unmarshall(res.Item); + if (!lead.nonVotingMember) return true; + } + } catch (e) { if (e instanceof BaseError) { throw e; } logger.error(e); throw new DatabaseFetchError({ message: `Failed to check lead status for ${username} in org ${orgName}.`, }); } }Optional: replace the loop with a single BatchGetItem across all orgs for a single network round trip.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Cache: Disabled due to data retention organization setting
Knowledge base: Disabled due to data retention organization setting
📒 Files selected for processing (9)
src/api/functions/github.ts(1 hunks)src/api/functions/organizations.ts(1 hunks)src/api/routes/organizations.ts(4 hunks)src/api/sqs/handlers/createOrgGithubTeam.ts(1 hunks)src/api/sqs/handlers/index.ts(1 hunks)src/api/sqs/handlers/syncExecCouncil.ts(1 hunks)src/api/sqs/index.ts(2 hunks)src/common/config.ts(3 hunks)src/common/types/sqsMessage.ts(3 hunks)
🧰 Additional context used
🪛 ESLint
src/api/sqs/handlers/index.ts
[error] 7-7: Unexpected use of file extension "js" for "./syncExecCouncil.js"
(import/extensions)
src/api/sqs/handlers/syncExecCouncil.ts
[error] 1-1: Resolve error: EACCES: permission denied, open '/ySwLhLIcEr'
at Object.writeFileSync (node:fs:2409:20)
at l (/home/jailuser/git/node_modules/get-tsconfig/dist/index.cjs:7:13685)
at createFilesMatcher (/home/jailuser/git/node_modules/get-tsconfig/dist/index.cjs:7:14437)
at Object.resolve (/home/jailuser/git/node_modules/eslint-import-resolver-typescript/lib/index.cjs:298:107)
at withResolver (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:180:23)
at fullResolve (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:201:22)
at relative (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:217:10)
at resolve (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:233:12)
at checkFileExtension (/home/jailuser/git/node_modules/eslint-plugin-import/lib/rules/extensions.js:205:53)
at checkSourceValue (/home/jailuser/git/node_modules/eslint-module-utils/moduleVisitor.js:32:5)
(import/extensions)
[error] 1-1: Unexpected use of file extension "js" for "common/types/sqsMessage.js"
(import/extensions)
[error] 6-6: Unexpected use of file extension "js" for "../index.js"
(import/extensions)
[error] 12-12: Unexpected use of file extension "js" for "common/config.js"
(import/extensions)
[error] 13-13: Unexpected use of file extension "js" for "api/functions/organizations.js"
(import/extensions)
[error] 18-18: Unexpected use of file extension "js" for "api/functions/entraId.js"
(import/extensions)
[error] 20-20: Unexpected use of file extension "js" for "common/types/iam.js"
(import/extensions)
src/common/types/sqsMessage.ts
[error] 83-83: Replace ·z.object({}) with ⏎····z.object({}),
(prettier/prettier)
[error] 102-102: Insert ,
(prettier/prettier)
src/api/routes/organizations.ts
[error] 1-1: Resolve error: EACCES: permission denied, open '/iDtwctVzgQ'
at Object.writeFileSync (node:fs:2409:20)
at l (/home/jailuser/git/node_modules/get-tsconfig/dist/index.cjs:7:13685)
at createFilesMatcher (/home/jailuser/git/node_modules/get-tsconfig/dist/index.cjs:7:14437)
at Object.resolve (/home/jailuser/git/node_modules/eslint-import-resolver-typescript/lib/index.cjs:298:107)
at withResolver (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:180:23)
at fullResolve (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:201:22)
at relative (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:217:10)
at resolve (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:233:12)
at checkFileExtension (/home/jailuser/git/node_modules/eslint-plugin-import/lib/rules/extensions.js:205:53)
at checkSourceValue (/home/jailuser/git/node_modules/eslint-module-utils/moduleVisitor.js:32:5)
(import/extensions)
[error] 42-42: Unexpected use of file extension "js" for "api/functions/entraId.js"
(import/extensions)
[error] 44-44: Unexpected use of file extension "js" for "api/functions/sts.js"
(import/extensions)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Build Application
- GitHub Check: Run Unit Tests
🔇 Additional comments (7)
src/common/config.ts (2)
144-146: Confirm parent team IDs are correct in both envs.If the parent team is ever not set for an env, callers should omit the parent instead of passing an undefined number. See suggested guard in github.ts comment.
Also applies to: 176-177
33-36: Verified: no stale ExecGithubTeam references found.The rename to
OrgAdminGithubParentTeamis complete—no orphaned references to the old key exist in the codebase.src/common/types/sqsMessage.ts (1)
11-12: Enum addition is fine; ensure queue-level gating if needed.If SyncExecCouncil should be limited to a specific SQS queue, add it to restrictedQueues in src/api/sqs/index.ts.
src/api/sqs/handlers/createOrgGithubTeam.ts (1)
92-96: Switch to OrgAdminGithubParentTeam looks correct.No behavior change. Ensure this value exists for all envs; if an env intentionally omits a parent, the GitHub request should skip parent_team_id (see github.ts suggestion).
src/api/sqs/index.ts (1)
21-23: Wiring LGTM.Dispatch table correctly maps CreateOrgGithubTeam and SyncExecCouncil. Consider queue gating for SyncExecCouncil if appropriate (restrictedQueues).
Also applies to: 45-46
src/api/routes/organizations.ts (2)
487-488: LGTM: Clear documentation of architectural change.The comment effectively explains that exec council membership management has been moved from dynamic membership rules to an SQS-based sync handler, making the code easier to understand for future maintainers.
565-577: Exec council sync implementation is correct and properly handles the sync workflow.The unconditional queuing ensures voting member changes are consistently reflected in the exec council. The handler correctly queries the source of truth (DynamoDB for voting leads, Entra ID for current members), performs selective adds/removes, and handles individual operation failures gracefully.
| export async function getAllVotingLeads({ | ||
| dynamoClient, | ||
| includeGroupIds, | ||
| logger, | ||
| }: { | ||
| dynamoClient: DynamoDBClient; | ||
| includeGroupIds?: string[]; | ||
| }): Promise<string | null> { | ||
| const command = new ScanCommand({ | ||
| TableName: genericConfig.SigInfoTableName, | ||
| IndexName: "LeadsGroupIdIndex", | ||
| logger: ValidLoggers; | ||
| }): Promise< | ||
| Array<{ username: string; org: string; name: string; title: string }> | ||
| > { | ||
| // Query all organizations in parallel for better performance | ||
| const queryPromises = AllOrganizationNameList.map(async (orgName) => { | ||
| const leadsQuery = new QueryCommand({ | ||
| TableName: genericConfig.SigInfoTableName, | ||
| KeyConditionExpression: "primaryKey = :leadName", | ||
| ExpressionAttributeValues: { | ||
| ":leadName": { S: `LEAD#${orgName}` }, | ||
| }, | ||
| ConsistentRead: true, | ||
| }); | ||
|
|
||
| try { | ||
| const responseMarshall = await dynamoClient.send(leadsQuery); | ||
| if (responseMarshall.Items) { | ||
| return responseMarshall.Items.map((x) => unmarshall(x)) | ||
| .filter((x) => x.username && !x.nonVotingMember) | ||
| .map((x) => ({ | ||
| username: x.username as string, | ||
| org: orgName, | ||
| name: x.name as string, | ||
| title: x.title as string, | ||
| })); | ||
| } | ||
| return []; | ||
| } catch (e) { | ||
| if (e instanceof BaseError) { | ||
| throw e; | ||
| } | ||
| logger.error(e); | ||
| throw new DatabaseFetchError({ | ||
| message: `Failed to get leads for org ${orgName}.`, | ||
| }); | ||
| } | ||
| }); | ||
| const results = await dynamoClient.send(command); | ||
| if (!results || !results.Items || results.Items.length === 0) { | ||
| return null; | ||
| } | ||
| const entries = results.Items.map((x) => unmarshall(x)) as { | ||
| primaryKey: string; | ||
| leadsEntraGroupId: string; | ||
| }[]; | ||
| const groupIds = entries | ||
| .filter((x) => x.primaryKey.startsWith("DEFINE#")) | ||
| .map((x) => x.leadsEntraGroupId); | ||
|
|
||
| if (groupIds.length === 0) { | ||
| return null; | ||
|
|
||
| const results = await Promise.all(queryPromises); | ||
| return results.flat(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix return type for title (can be undefined) + trim payload + optional resiliency
- Title may be missing on some lead items; current return type promises string. Make it optional to avoid misleading consumers and potential runtime issues. Also project only needed attributes to cut payload. Optionally, return partial results if one org read fails.
Apply:
export async function getAllVotingLeads({
dynamoClient,
logger,
}: {
dynamoClient: DynamoDBClient;
- logger: ValidLoggers;
-}): Promise<
- Array<{ username: string; org: string; name: string; title: string }>
-> {
+ logger: ValidLoggers;
+}): Promise<
+ Array<{ username: string; org: string; name: string; title?: string }>
+> {
// Query all organizations in parallel for better performance
const queryPromises = AllOrganizationNameList.map(async (orgName) => {
const leadsQuery = new QueryCommand({
TableName: genericConfig.SigInfoTableName,
KeyConditionExpression: "primaryKey = :leadName",
ExpressionAttributeValues: {
":leadName": { S: `LEAD#${orgName}` },
},
+ ProjectionExpression: "#u, #n, title, nonVotingMember",
+ ExpressionAttributeNames: { "#u": "username", "#n": "name" },
ConsistentRead: true,
});
try {
const responseMarshall = await dynamoClient.send(leadsQuery);
if (responseMarshall.Items) {
return responseMarshall.Items.map((x) => unmarshall(x))
.filter((x) => x.username && !x.nonVotingMember)
.map((x) => ({
username: x.username as string,
org: orgName,
- name: x.name as string,
- title: x.title as string,
+ name: x.name as string,
+ title: x.title,
}));
}
return [];
} catch (e) {
if (e instanceof BaseError) {
throw e;
}
logger.error(e);
throw new DatabaseFetchError({
message: `Failed to get leads for org ${orgName}.`,
});
}
});
- const results = await Promise.all(queryPromises);
- return results.flat();
+ // Option B (resiliency): tolerate one-org failures and return partial results
+ // const settled = await Promise.allSettled(queryPromises);
+ // const results: Array<{ username: string; org: string; name: string; title?: string }> = [];
+ // for (const s of settled) {
+ // if (s.status === "fulfilled") results.push(...s.value);
+ // else logger.error(s.reason);
+ // }
+ // return results;
+ const results = await Promise.all(queryPromises);
+ return results.flat();
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function getAllVotingLeads({ | |
| dynamoClient, | |
| includeGroupIds, | |
| logger, | |
| }: { | |
| dynamoClient: DynamoDBClient; | |
| includeGroupIds?: string[]; | |
| }): Promise<string | null> { | |
| const command = new ScanCommand({ | |
| TableName: genericConfig.SigInfoTableName, | |
| IndexName: "LeadsGroupIdIndex", | |
| logger: ValidLoggers; | |
| }): Promise< | |
| Array<{ username: string; org: string; name: string; title: string }> | |
| > { | |
| // Query all organizations in parallel for better performance | |
| const queryPromises = AllOrganizationNameList.map(async (orgName) => { | |
| const leadsQuery = new QueryCommand({ | |
| TableName: genericConfig.SigInfoTableName, | |
| KeyConditionExpression: "primaryKey = :leadName", | |
| ExpressionAttributeValues: { | |
| ":leadName": { S: `LEAD#${orgName}` }, | |
| }, | |
| ConsistentRead: true, | |
| }); | |
| try { | |
| const responseMarshall = await dynamoClient.send(leadsQuery); | |
| if (responseMarshall.Items) { | |
| return responseMarshall.Items.map((x) => unmarshall(x)) | |
| .filter((x) => x.username && !x.nonVotingMember) | |
| .map((x) => ({ | |
| username: x.username as string, | |
| org: orgName, | |
| name: x.name as string, | |
| title: x.title as string, | |
| })); | |
| } | |
| return []; | |
| } catch (e) { | |
| if (e instanceof BaseError) { | |
| throw e; | |
| } | |
| logger.error(e); | |
| throw new DatabaseFetchError({ | |
| message: `Failed to get leads for org ${orgName}.`, | |
| }); | |
| } | |
| }); | |
| const results = await dynamoClient.send(command); | |
| if (!results || !results.Items || results.Items.length === 0) { | |
| return null; | |
| } | |
| const entries = results.Items.map((x) => unmarshall(x)) as { | |
| primaryKey: string; | |
| leadsEntraGroupId: string; | |
| }[]; | |
| const groupIds = entries | |
| .filter((x) => x.primaryKey.startsWith("DEFINE#")) | |
| .map((x) => x.leadsEntraGroupId); | |
| if (groupIds.length === 0) { | |
| return null; | |
| const results = await Promise.all(queryPromises); | |
| return results.flat(); | |
| } | |
| export async function getAllVotingLeads({ | |
| dynamoClient, | |
| logger, | |
| }: { | |
| dynamoClient: DynamoDBClient; | |
| logger: ValidLoggers; | |
| }): Promise< | |
| Array<{ username: string; org: string; name: string; title?: string }> | |
| > { | |
| // Query all organizations in parallel for better performance | |
| const queryPromises = AllOrganizationNameList.map(async (orgName) => { | |
| const leadsQuery = new QueryCommand({ | |
| TableName: genericConfig.SigInfoTableName, | |
| KeyConditionExpression: "primaryKey = :leadName", | |
| ExpressionAttributeValues: { | |
| ":leadName": { S: `LEAD#${orgName}` }, | |
| }, | |
| ProjectionExpression: "#u, #n, title, nonVotingMember", | |
| ExpressionAttributeNames: { "#u": "username", "#n": "name" }, | |
| ConsistentRead: true, | |
| }); | |
| try { | |
| const responseMarshall = await dynamoClient.send(leadsQuery); | |
| if (responseMarshall.Items) { | |
| return responseMarshall.Items.map((x) => unmarshall(x)) | |
| .filter((x) => x.username && !x.nonVotingMember) | |
| .map((x) => ({ | |
| username: x.username as string, | |
| org: orgName, | |
| name: x.name as string, | |
| title: x.title, | |
| })); | |
| } | |
| return []; | |
| } catch (e) { | |
| if (e instanceof BaseError) { | |
| throw e; | |
| } | |
| logger.error(e); | |
| throw new DatabaseFetchError({ | |
| message: `Failed to get leads for org ${orgName}.`, | |
| }); | |
| } | |
| }); | |
| // Option B (resiliency): tolerate one-org failures and return partial results | |
| // const settled = await Promise.allSettled(queryPromises); | |
| // const results: Array<{ username: string; org: string; name: string; title?: string }> = []; | |
| // for (const s of settled) { | |
| // if (s.status === "fulfilled") results.push(...s.value); | |
| // else logger.error(s.reason); | |
| // } | |
| // return results; | |
| const results = await Promise.all(queryPromises); | |
| return results.flat(); | |
| } |
🤖 Prompt for AI Agents
In src/api/functions/organizations.ts around lines 533 to 579, the function
currently declares title as string, fetches full items, and throws if any org
query fails; change the return type so title is optional (title?: string),
restrict the DynamoDB query to only the needed attributes using
ProjectionExpression (e.g., username, name, title) and ExpressionAttributeNames
to reduce payload, and make the per-org catch block resilient by logging the
error and returning an empty array for that org instead of throwing (still
rethrow BaseError). Ensure callers/platform types are updated to accept title as
possibly undefined.
| import { AllOrganizationNameList, getOrgByName } from "@acm-uiuc/js-shared"; | ||
| import rateLimiter from "api/plugins/rateLimiter.js"; | ||
| import { withRoles, withTags } from "api/components/index.js"; | ||
| import { z } from "zod/v4"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Verify Zod v4 compatibility across the codebase.
The import has been updated to use zod/v4, which introduces breaking changes. While the usage in this file appears compatible, ensure that other files using Zod schemas have been reviewed for:
- Default value handling changes (
.default()now short-circuits onundefined) - Number validation changes (no infinite values,
.int()only allows safe integers) - Record schema changes (now requires both key and value schemas)
- Function schema redesign
- Enum handling changes
🏁 Script executed:
#!/bin/bash
# Description: Search for Zod patterns that may be affected by v4 breaking changes
echo "=== Checking for .default() usage with transforms ==="
rg -n "\.transform\(" --type=ts -A 5 | rg "\.default\("
echo -e "\n=== Checking for z.record() without explicit schemas ==="
rg -n "z\.record\([^,)]*\)" --type=ts
echo -e "\n=== Checking for z.function() usage ==="
rg -n "z\.function\(" --type=ts
echo -e "\n=== Checking for number validation with potential infinity ==="
rg -n "z\.number\(\)" --type=ts -A 2 | rg -i "infinite|infinity"Length of output: 309
🏁 Script executed:
#!/bin/bash
# Get the full line from events.ts for the z.record() call
echo "=== Full z.record() line from events.ts ==="
sed -n '8p' src/common/types/events.ts
echo -e "\n=== Verify z.record() context (lines 5-12) ==="
sed -n '5,12p' src/common/types/events.ts
echo -e "\n=== Check all Zod version imports in the codebase ==="
rg -n "from\s+[\"']zod" --type=ts
echo -e "\n=== Check for .merge() chains or other schema composition ==="
rg -n "\.merge\(" --type=ts | head -20
echo -e "\n=== Check for .pick() or .omit() usage ==="
rg -n "\.pick\(|\.omit\(" --type=ts | head -20Length of output: 3811
🏁 Script executed:
#!/bin/bash
# Check if api/functions/organizations.ts is related to the reviewed file
echo "=== Check what's imported from api/functions/organizations.ts ==="
rg -n "from.*api/functions/organizations" --type=ts
echo -e "\n=== Check what api/functions/organizations.ts exports ==="
head -50 src/api/functions/organizations.ts | grep -E "export|^import"
echo -e "\n=== Check if api/functions/organizations.ts was modified in this PR ==="
rg -n "import.*organizations" src/api/routes/organizations.ts
echo -e "\n=== Verify Zod import resolution - check if 'zod' resolves to v4 ==="
rg -n "\"zod\"[^/]" --type=ts | head -10Length of output: 1825
Update src/api/functions/organizations.ts to use Zod v4.
The reviewed file imports from api/functions/organizations.ts, which still uses import z from "zod" instead of import z from "zod/v4". This creates a Zod version mismatch that can cause validation and type compatibility issues. Update line 20 of src/api/functions/organizations.ts to:
import z from "zod/v4";Additionally, verify all other files importing from this module are compatible with the updated import.
🤖 Prompt for AI Agents
In src/api/functions/organizations.ts around line 20, the file currently imports
Zod using the old path; change the import to use Zod v4 by replacing the
existing import with: import z from "zod/v4"; then run a quick grep across the
repo for imports of this module and ensure callers remain compatible with the v4
export (update any imports or type usages if necessary) and run the test/compile
step to confirm no type or runtime errors.
| export { sendSaleEmailHandler } from "./sendSaleEmailHandler.js"; | ||
| export { emailNotificationsHandler } from "./emailNotifications.js"; | ||
| export { createOrgGithubTeamHandler } from "./createOrgGithubTeam.js"; | ||
| export { syncExecCouncilHandler } from "./syncExecCouncil.js"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
ESLint import/extensions warning.
If using TS + ESM with NodeNext and emitted .js paths, keep the extension and relax the rule; otherwise drop the extension for local exports.
🏁 Script executed:
#!/bin/bash
# Show existing pattern in this repo
rg -nP '^export\s+\{.*\}\s+from\s+"\.\/.*\.js";' src/api/sqs/handlers | wc -lLength of output: 1
---
🏁 Script executed:
```shell
#!/bin/bash
# Check tsconfig.json for module resolution strategy
cat tsconfig.json 2>/dev/null | grep -A5 -B5 "moduleResolution\|module"
Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check for ESLint configuration files and import/extensions rule
find . -maxdepth 2 -type f \( -name ".eslintrc*" -o -name "eslint.config.*" \) 2>/dev/null | head -5Length of output: 19
🏁 Script executed:
#!/bin/bash
# Check package.json for ESLint config and import/extensions rule mention
cat package.json 2>/dev/null | grep -A20 '"eslintConfig"' | head -30Length of output: 0
🏁 Script executed:
#!/bin/bash
# Show actual exports with .js in src/api/sqs/handlers to confirm pattern
rg -nP '^export\s+\{.*\}\s+from\s+"\.\/.*\.js";' src/api/sqs/handlersLength of output: 680
---
🏁 Script executed:
```shell
#!/bin/bash
# Read eslint.config.mjs to see import/extensions rule
cat eslint.config.mjs | head -100
Length of output: 2565
🏁 Script executed:
#!/bin/bash
# Read full tsconfig.json
cat tsconfig.jsonLength of output: 45
🏁 Script executed:
#!/bin/bash
# Check if .js extensions are used elsewhere in codebase (not just handlers)
rg -nP '^export\s+\{.*\}\s+from\s+"\.\/.*\.js";' --type=ts --max-count=20 | head -20Length of output: 680
Remove .js extensions from all export statements—they violate the ESLint rule.
The project's ESLint configuration explicitly forbids .js extensions on local imports and exports ("import/extensions": ["error", "ignorePackages", { js: "never", ... }] at eslint.config.mjs). All 7 exports at lines 1–7 must drop the .js extension to comply:
export { syncExecCouncilHandler } from "./syncExecCouncil";
🧰 Tools
🪛 ESLint
[error] 7-7: Unexpected use of file extension "js" for "./syncExecCouncil.js"
(import/extensions)
🤖 Prompt for AI Agents
In src/api/sqs/handlers/index.ts around line 7, the export statement includes a
.js extension which violates the project's ESLint import/extensions rule; remove
the .js extension from this and the other local export statements so they read
e.g. export { syncExecCouncilHandler } from "./syncExecCouncil" and repeat for
all seven exports to comply with the lint rule.
| // Convert to set of usernames (without @illinois.edu) | ||
| const votingLeadUsernames = new Set( | ||
| votingLeads.map((lead) => lead.username), | ||
| ); | ||
|
|
||
| logger.info( | ||
| `Found ${votingLeadUsernames.size} voting leads across all organizations.`, | ||
| ); | ||
|
|
||
| // Get current exec council members from Entra ID | ||
| const currentMembers = await listGroupMembers( | ||
| entraIdToken, | ||
| execCouncilGroup, | ||
| ); | ||
|
|
||
| // Convert to set of emails | ||
| const currentMemberEmails = new Set( | ||
| currentMembers | ||
| .map((member) => member.email) | ||
| .filter((email) => email && email.endsWith("@illinois.edu")), | ||
| ); | ||
|
|
||
| logger.info( | ||
| `Current exec council has ${currentMemberEmails.size} members from @illinois.edu domain.`, | ||
| ); | ||
|
|
||
| // Determine who to add and who to remove | ||
| const toAdd = Array.from(votingLeadUsernames).filter( | ||
| (username) => !currentMemberEmails.has(username), | ||
| ); | ||
| const toRemove = Array.from(currentMemberEmails).filter( | ||
| (email) => !votingLeadUsernames.has(email), | ||
| ); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Normalize identifiers: comparing usernames to emails will produce incorrect add/remove sets.
You build a Set of usernames then compare against email addresses, leading to false diffs and unintended membership churn.
- // Convert to set of usernames (without @illinois.edu)
- const votingLeadUsernames = new Set(
- votingLeads.map((lead) => lead.username),
- );
+ // Normalize voting leads to lowercased @illinois.edu emails
+ const EMAIL_DOMAIN = "@illinois.edu";
+ const votingLeadEmails = new Set(
+ votingLeads
+ .map((lead) => lead.username?.toLowerCase())
+ .filter((u): u is string => !!u && u.length > 0)
+ .map((u) => (u.includes("@") ? u : `${u}${EMAIL_DOMAIN}`))
+ );
@@
- const currentMemberEmails = new Set(
- currentMembers
- .map((member) => member.email)
- .filter((email) => email && email.endsWith("@illinois.edu")),
- );
+ const currentMemberEmails = new Set(
+ currentMembers
+ .map((member) => member.email?.toLowerCase())
+ .filter((email): email is string => !!email && email.endsWith("@illinois.edu")),
+ );
@@
- const toAdd = Array.from(votingLeadUsernames).filter(
- (username) => !currentMemberEmails.has(username),
- );
- const toRemove = Array.from(currentMemberEmails).filter(
- (email) => !votingLeadUsernames.has(email),
- );
+ const toAdd = Array.from(votingLeadEmails).filter(
+ (email) => !currentMemberEmails.has(email),
+ );
+ const toRemove = Array.from(currentMemberEmails).filter(
+ (email) => !votingLeadEmails.has(email),
+ );
@@
- for (const username of toAdd) {
+ for (const email of toAdd) {
try {
- logger.info(`Adding ${username} to exec council...`);
+ logger.info(`Adding ${email} to exec council...`);
await modifyGroup(
entraIdToken,
- username,
+ email,
execCouncilGroup,
EntraGroupActions.ADD,
dynamo,
);
- logger.info(`Successfully added ${username} to exec council.`);
+ logger.info(`Successfully added ${email} to exec council.`);
} catch (error) {
logger.error(
error,
- `Failed to add ${username} to exec council. Continuing with other members...`,
+ `Failed to add ${email} to exec council. Continuing with other members...`,
);
}
}🏁 Script executed:
#!/bin/bash
# Inspect getAllVotingLeads return shape/usages to confirm username semantics
rg -nP --type=ts -C2 '\bgetAllVotingLeads\s*\('
rg -nP --type=ts -C2 '\busername\b' api/functions | head -n 50Length of output: 731
I need to gather more information about the return type of getAllVotingLeads and the modifyGroup function signature to verify the review comment's claim. Let me inspect those now.
---
🏁 Script executed:
```shell
#!/bin/bash
# Get the full implementation of getAllVotingLeads to see return type
rg -nP --type=ts -A30 'export async function getAllVotingLeads' src/api/functions/organizations.ts
# Get modifyGroup function signature and implementation
rg -nP --type=ts -A10 'function modifyGroup|export.*modifyGroup' src/
Length of output: 1857
Now I need to see the complete implementation in syncExecCouncil.ts to verify how usernames are being passed to modifyGroup. Let me check the full code around the add/remove loops:
---
🏁 Script executed:
```shell
#!/bin/bash
# Get the complete implementation of the sync handler, particularly the toAdd/toRemove loops
rg -nP --type=ts -A20 'for \(const username of toAdd\)|for \(const.*of toAdd\)' src/api/sqs/handlers/syncExecCouncil.ts
Length of output: 686
Normalize identifiers: comparing usernames to emails will produce incorrect add/remove sets.
You build a Set of usernames then compare against email addresses, leading to false diffs and unintended membership churn. The modifyGroup function expects an email (line 262 of src/api/functions/entraId.ts) and validates it ends with @illinois.edu (line 268), but the code passes raw usernames without domain.
- // Convert to set of usernames (without @illinois.edu)
- const votingLeadUsernames = new Set(
- votingLeads.map((lead) => lead.username),
- );
+ // Normalize voting leads to lowercased @illinois.edu emails
+ const EMAIL_DOMAIN = "@illinois.edu";
+ const votingLeadEmails = new Set(
+ votingLeads
+ .map((lead) => lead.username?.toLowerCase())
+ .filter((u): u is string => !!u && u.length > 0)
+ .map((u) => (u.includes("@") ? u : `${u}${EMAIL_DOMAIN}`))
+ );
@@
- const currentMemberEmails = new Set(
- currentMembers
- .map((member) => member.email)
- .filter((email) => email && email.endsWith("@illinois.edu")),
- );
+ const currentMemberEmails = new Set(
+ currentMembers
+ .map((member) => member.email?.toLowerCase())
+ .filter((email): email is string => !!email && email.endsWith("@illinois.edu")),
+ );
@@
- const toAdd = Array.from(votingLeadUsernames).filter(
- (username) => !currentMemberEmails.has(username),
- );
- const toRemove = Array.from(currentMemberEmails).filter(
- (email) => !votingLeadUsernames.has(email),
- );
+ const toAdd = Array.from(votingLeadEmails).filter(
+ (email) => !currentMemberEmails.has(email),
+ );
+ const toRemove = Array.from(currentMemberEmails).filter(
+ (email) => !votingLeadEmails.has(email),
+ );
@@
- for (const username of toAdd) {
+ for (const email of toAdd) {
try {
- logger.info(`Adding ${username} to exec council...`);
+ logger.info(`Adding ${email} to exec council...`);
await modifyGroup(
entraIdToken,
- username,
+ email,
execCouncilGroup,
EntraGroupActions.ADD,
dynamo,
);
- logger.info(`Successfully added ${username} to exec council.`);
+ logger.info(`Successfully added ${email} to exec council.`);
} catch (error) {
logger.error(
error,
- `Failed to add ${username} to exec council. Continuing with other members...`,
+ `Failed to add ${email} to exec council. Continuing with other members...`,
);
}
}🤖 Prompt for AI Agents
In src/api/sqs/handlers/syncExecCouncil.ts around lines 58 to 91, the code
builds a Set of bare usernames and compares it to a Set of @illinois.edu emails
which produces incorrect add/remove lists; change the normalization to use the
same identifier for both sides (emails): convert votingLeads' usernames into
full emails (append @illinois.edu, validate/trim/lowercase the username first)
before creating votingLeadEmails Set, ensure currentMemberEmails are lowercased
and filtered for the @illinois.edu domain, then compute toAdd and toRemove by
comparing these email Sets so the values passed to modifyGroup are valid
@illinois.edu addresses.
| ), | ||
| [AvailableSQSFunctions.SyncExecCouncil]: createSQSSchema( | ||
| AvailableSQSFunctions.SyncExecCouncil, z.object({}) | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix Prettier violations and add trailing comma in union.
Tidy formatting to satisfy prettier/prettier.
[AvailableSQSFunctions.CreateOrgGithubTeam]: createSQSSchema(
AvailableSQSFunctions.CreateOrgGithubTeam, z.object({
orgName: z.enum(AllOrganizationNameList),
githubTeamName: z.string().min(1),
githubTeamDescription: z.string().min(1)
})
- ),
- [AvailableSQSFunctions.SyncExecCouncil]: createSQSSchema(
- AvailableSQSFunctions.SyncExecCouncil, z.object({})
- )
+ ),
+ [AvailableSQSFunctions.SyncExecCouncil]: createSQSSchema(
+ AvailableSQSFunctions.SyncExecCouncil,
+ z.object({}),
+ ),
} as const;
...
export const sqsPayloadSchema = z.discriminatedUnion("function", [
sqsPayloadSchemas[AvailableSQSFunctions.Ping],
sqsPayloadSchemas[AvailableSQSFunctions.EmailMembershipPass],
sqsPayloadSchemas[AvailableSQSFunctions.ProvisionNewMember],
sqsPayloadSchemas[AvailableSQSFunctions.SendSaleEmail],
sqsPayloadSchemas[AvailableSQSFunctions.EmailNotifications],
- sqsPayloadSchemas[AvailableSQSFunctions.CreateOrgGithubTeam],
- sqsPayloadSchemas[AvailableSQSFunctions.SyncExecCouncil]
+ sqsPayloadSchemas[AvailableSQSFunctions.CreateOrgGithubTeam],
+ sqsPayloadSchemas[AvailableSQSFunctions.SyncExecCouncil],
] as
const);Also applies to: 101-103
🧰 Tools
🪛 ESLint
[error] 83-83: Replace ·z.object({}) with ⏎····z.object({}),
(prettier/prettier)
[error] 84-84: Insert ,
(prettier/prettier)
🤖 Prompt for AI Agents
In src/common/types/sqsMessage.ts around lines 81-84 (and also apply same fix to
101-103), the block defining the schema has Prettier violations and is missing a
trailing comma in the union; reformat the multi-line createSQSSchema call to
satisfy Prettier (proper indentation, line breaks) and add a trailing comma
after the last array entry/argument so the union and object literal end with a
comma; ensure the same formatting and trailing-comma fix is applied to lines
101-103 as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/api/sqs/handlers/syncExecCouncil.ts (1)
88-121: Identifier normalization issue persists from previous review.The username-to-email comparison bug identified in the previous review remains unaddressed. Bare usernames are compared against
@illinois.eduemails, causing incorrect add/remove operations and failures when passing usernames tomodifyGroup(which expects emails).Please apply the normalization fix proposed in the previous review comment.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Cache: Disabled due to data retention organization setting
Knowledge base: Disabled due to data retention organization setting
📒 Files selected for processing (1)
src/api/sqs/handlers/syncExecCouncil.ts(1 hunks)
🧰 Additional context used
🪛 ESLint
src/api/sqs/handlers/syncExecCouncil.ts
[error] 1-1: Resolve error: EACCES: permission denied, open '/WDDlCXpPkc'
at Object.writeFileSync (node:fs:2409:20)
at l (/home/jailuser/git/node_modules/get-tsconfig/dist/index.cjs:7:13685)
at createFilesMatcher (/home/jailuser/git/node_modules/get-tsconfig/dist/index.cjs:7:14437)
at Object.resolve (/home/jailuser/git/node_modules/eslint-import-resolver-typescript/lib/index.cjs:298:107)
at withResolver (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:180:23)
at fullResolve (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:201:22)
at relative (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:217:10)
at resolve (/home/jailuser/git/node_modules/eslint-module-utils/resolve.js:233:12)
at checkFileExtension (/home/jailuser/git/node_modules/eslint-plugin-import/lib/rules/extensions.js:205:53)
at checkSourceValue (/home/jailuser/git/node_modules/eslint-module-utils/moduleVisitor.js:32:5)
(import/extensions)
[error] 1-1: Unexpected use of file extension "js" for "common/types/sqsMessage.js"
(import/extensions)
[error] 6-6: Unexpected use of file extension "js" for "../index.js"
(import/extensions)
[error] 13-13: Unexpected use of file extension "js" for "common/config.js"
(import/extensions)
[error] 14-14: Unexpected use of file extension "js" for "api/functions/organizations.js"
(import/extensions)
[error] 19-19: Unexpected use of file extension "js" for "api/functions/entraId.js"
(import/extensions)
[error] 21-21: Unexpected use of file extension "js" for "common/types/iam.js"
(import/extensions)
[error] 22-22: Unexpected use of file extension "js" for "api/functions/sts.js"
(import/extensions)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Run Unit Tests
- GitHub Check: Build Application
🔇 Additional comments (1)
src/api/sqs/handlers/syncExecCouncil.ts (1)
127-144: Good error handling for individual member additions.The per-member try-catch pattern allows the sync to continue even if individual additions fail, which is appropriate for bulk operations.
| const dynamo = new DynamoDBClient({ | ||
| region: genericConfig.AwsRegion, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the authorized DynamoDB client from getAuthorizedClients.
A second DynamoDB client is created here but the code later uses this default client instead of the authorized client returned from getAuthorizedClients() (line 64). When roleArns.Entra is set, the authorized client has assumed role credentials, but line 84 and line 135 use this default client, bypassing the intended credential chain.
Apply this diff to use the authorized client consistently:
- const dynamo = new DynamoDBClient({
- region: genericConfig.AwsRegion,
- });
-
try {
const clients = await getAuthorizedClients();
+ const dynamo = clients.dynamoClient;
const entraIdToken = await getEntraIdToken({📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const dynamo = new DynamoDBClient({ | |
| region: genericConfig.AwsRegion, | |
| }); | |
| try { | |
| const clients = await getAuthorizedClients(); | |
| const dynamo = clients.dynamoClient; | |
| const entraIdToken = await getEntraIdToken({ |
🤖 Prompt for AI Agents
In src/api/sqs/handlers/syncExecCouncil.ts around lines 59 to 61, a new default
DynamoDBClient is instantiated but the intended authorized client from
getAuthorizedClients() should be used throughout; replace usages of the locally
created `dynamo` with the authorized DynamoDB client returned by
getAuthorizedClients() (or remove the redundant instantiation and assign the
authorized client to the `dynamo` variable) so that all calls (including the
ones at lines ~84 and ~135) use the assumed-role credentials when roleArns.Entra
is set.
This allows us to have some people who are not exec representatives but do have org admin privileges.
Summary by CodeRabbit
New Features
Refactor