diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcbd3dbe..92415c9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,17 @@ jobs: with: pulumi-version: '3.197.0' + - name: Cache Pulumi plugins + uses: actions/cache@v4 + with: + path: ~/.pulumi/plugins + key: pulumi-plugins-${{ hashFiles('Pulumi.yaml') }} + restore-keys: | + pulumi-plugins- + - name: Install Pulumi packages + env: + GITHUB_TOKEN: ${{ github.token }} run: pulumi install - name: Install dependencies diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 951800f2..aea1d56d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,7 +34,17 @@ jobs: with: pulumi-version: ${{ env.PULUMI_VERSION }} + - name: Cache Pulumi plugins + uses: actions/cache@v4 + with: + path: ~/.pulumi/plugins + key: pulumi-plugins-${{ hashFiles('Pulumi.yaml') }} + restore-keys: | + pulumi-plugins- + - name: Install Pulumi packages + env: + GITHUB_TOKEN: ${{ github.token }} run: pulumi install - name: Install dependencies @@ -52,6 +62,10 @@ jobs: env: PULUMI_PASSPHRASE: ${{ secrets.PULUMI_PROD_PASSPHRASE }} GITHUB_TOKEN: ${{ secrets.PULUMI_GITHUB_TOKEN }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} run: | echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt + pulumi config set discord:guildId "$DISCORD_GUILD_ID" --stack prod + pulumi config set discord:botToken "$DISCORD_BOT_TOKEN" --secret --stack prod make up \ No newline at end of file diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..8f5c8123 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,153 @@ +name: Preview + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: write + +env: + PULUMI_VERSION: "3.197.0" + +jobs: + preview: + name: Preview Changes + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Setup Pulumi + uses: pulumi/actions@v6 + with: + pulumi-version: ${{ env.PULUMI_VERSION }} + + - name: Cache Pulumi plugins + uses: actions/cache@v4 + with: + path: ~/.pulumi/plugins + key: pulumi-plugins-${{ hashFiles('Pulumi.yaml') }} + restore-keys: | + pulumi-plugins- + + - name: Install Pulumi packages + env: + GITHUB_TOKEN: ${{ github.token }} + run: pulumi install + + - name: Install dependencies + run: npm ci + + - name: Run validation + run: npm run check + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }} + + - name: Preview changes + id: preview + env: + PULUMI_PASSPHRASE: ${{ secrets.PULUMI_PROD_PASSPHRASE }} + GITHUB_TOKEN: ${{ secrets.PULUMI_GITHUB_TOKEN }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} + run: | + echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt + pulumi login gs://mcp-access-prod-pulumi-state + + # Build config flags for Discord if secrets are available + CONFIG_FLAGS="" + if [ -n "$DISCORD_GUILD_ID" ]; then + CONFIG_FLAGS="$CONFIG_FLAGS --config discord:guildId=$DISCORD_GUILD_ID" + fi + if [ -n "$DISCORD_BOT_TOKEN" ]; then + CONFIG_FLAGS="$CONFIG_FLAGS --config discord:botToken=$DISCORD_BOT_TOKEN" + fi + + # Run preview and capture output + set +e + PREVIEW_OUTPUT=$(PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi preview --stack prod --diff $CONFIG_FLAGS 2>&1) + PREVIEW_EXIT_CODE=$? + set -e + + # Save output for comment + echo "exit_code=$PREVIEW_EXIT_CODE" >> $GITHUB_OUTPUT + + # Write preview to file (handles multiline) + echo "$PREVIEW_OUTPUT" > preview_output.txt + + # Also print to logs + echo "$PREVIEW_OUTPUT" + + # Exit with preview exit code + exit $PREVIEW_EXIT_CODE + + - name: Comment on PR + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let output = ''; + try { + output = fs.readFileSync('preview_output.txt', 'utf8'); + } catch (e) { + output = 'Failed to read preview output'; + } + + // Truncate if too long for GitHub comment + const maxLength = 60000; + if (output.length > maxLength) { + output = output.substring(0, maxLength) + '\n\n... (truncated)'; + } + + const body = `## Pulumi Preview + +
+ Click to expand preview output + + \`\`\` + ${output} + \`\`\` + +
+ `; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes('## Pulumi Preview') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } diff --git a/package.json b/package.json index 59c9dd72..e23b7083 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "build": "tsc", "validate": "npx ts-node scripts/validate-config.ts", + "test": "npx ts-node scripts/test-config.ts", + "check": "npm run validate && npm run test", "format": "prettier --write .", "format:check": "prettier --check ." }, diff --git a/scripts/test-config.ts b/scripts/test-config.ts new file mode 100644 index 00000000..471bbf4d --- /dev/null +++ b/scripts/test-config.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env npx ts-node + +/** + * Tests the configuration structure without needing Pulumi credentials. + * Run with: npx ts-node scripts/test-config.ts + */ + +import { ROLES, buildRoleLookup, getRolesForPlatform } from '../src/config/roles'; +import { ROLE_IDS, isValidRoleId } from '../src/config/roleIds'; +import { MEMBERS } from '../src/config/users'; + +let passed = 0; +let failed = 0; + +function test(name: string, fn: () => boolean) { + try { + if (fn()) { + console.log(`✓ ${name}`); + passed++; + } else { + console.log(`✗ ${name}`); + failed++; + } + } catch (e) { + console.log(`✗ ${name}: ${e}`); + failed++; + } +} + +console.log('Testing role configuration...\n'); + +// Test ROLE_IDS +test('ROLE_IDS has entries', () => Object.keys(ROLE_IDS).length > 0); +test('All ROLE_IDS values are valid', () => + Object.values(ROLE_IDS).every((id) => isValidRoleId(id))); + +// Test ROLES +test('ROLES array is not empty', () => ROLES.length > 0); +test('All roles have id and description', () => ROLES.every((r) => r.id && r.description)); +test('All role IDs are unique', () => { + const ids = ROLES.map((r) => r.id); + return ids.length === new Set(ids).size; +}); + +// Test platform configs +const githubRoles = getRolesForPlatform('github'); +const discordRoles = getRolesForPlatform('discord'); +const googleRoles = getRolesForPlatform('google'); + +test('Has GitHub roles', () => githubRoles.length > 0); +test('Has Discord roles', () => discordRoles.length > 0); +test('Has Google roles', () => googleRoles.length > 0); + +test('GitHub roles have team names', () => githubRoles.every((r) => r.github?.team)); +test('Discord roles have role names', () => discordRoles.every((r) => r.discord?.role)); +test('Google roles have group names', () => googleRoles.every((r) => r.google?.group)); + +// Test parent relationships +const roleLookup = buildRoleLookup(); +test('All GitHub parent references are valid', () => + githubRoles.every((r) => { + if (!r.github?.parent) return true; + const parent = roleLookup.get(r.github.parent); + return parent && parent.github; + })); + +// Test members +test('MEMBERS array is not empty', () => MEMBERS.length > 0); +test('All members have at least one identifier', () => + MEMBERS.every((m) => m.github || m.email || m.discord)); +test('All member role references are valid', () => + MEMBERS.every((m) => m.memberOf.every((id) => roleLookup.has(id)))); + +// Test specific roles exist +test('CORE_MAINTAINERS role exists', () => !!roleLookup.get(ROLE_IDS.CORE_MAINTAINERS)); +test('ADMINISTRATORS role exists (Discord-only)', () => { + const role = roleLookup.get(ROLE_IDS.ADMINISTRATORS); + return role !== undefined && role.discord !== undefined && role.github === undefined; +}); +test('TYPESCRIPT_SDK_AUTH role exists (GitHub-only)', () => { + const role = roleLookup.get(ROLE_IDS.TYPESCRIPT_SDK_AUTH); + return role !== undefined && role.github !== undefined && role.discord === undefined; +}); + +// Summary +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index d8132467..59d8a78d 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -1,27 +1,27 @@ #!/usr/bin/env npx ts-node /** - * Validates that all team references in repoAccess.ts exist in groups.ts + * Validates that all references in the config are valid. * Run with: npx ts-node scripts/validate-config.ts */ -import { GROUPS } from '../src/config/groups'; +import { ROLES, buildRoleLookup } from '../src/config/roles'; import { REPOSITORY_ACCESS } from '../src/config/repoAccess'; import { MEMBERS } from '../src/config/users'; -import type { Group, Member } from '../src/config/utils'; +import type { RoleId } from '../src/config/roleIds'; -// Get all GitHub team names (groups not limited to google-only platforms) +const roleLookup = buildRoleLookup(); + +// Get all GitHub team names (roles that have GitHub config) const githubTeamNames = new Set(); -// Get all group names (for member validation - members can be in any group) -const allGroupNames = new Set(); +// Get all role IDs (for member validation) +const allRoleIds = new Set(); -for (const g of GROUPS) { - const group = g as Group; - allGroupNames.add(group.name); +for (const role of ROLES) { + allRoleIds.add(role.id); - const platforms = group.onlyOnPlatforms; - if (!platforms || platforms.includes('github')) { - githubTeamNames.add(group.name); + if (role.github) { + githubTeamNames.add(role.github.team); } } @@ -35,41 +35,42 @@ for (const repo of REPOSITORY_ACCESS) { for (const teamRef of repo.teams) { if (!githubTeamNames.has(teamRef.team)) { console.error( - `ERROR: Repository "${repo.repository}" references team "${teamRef.team}" which does not exist in groups.ts` + `ERROR: Repository "${repo.repository}" references team "${teamRef.team}" which does not exist in roles.ts` ); hasErrors = true; } } } -// Validate team references in MEMBERS (memberOf) -// Members can be in any group (GitHub or Google-only) -console.log('Validating team references in users.ts...'); -for (const m of MEMBERS) { - const member = m as Member; - for (const teamKey of member.memberOf) { - if (!allGroupNames.has(teamKey)) { +// Validate role references in MEMBERS (memberOf) +console.log('Validating role references in users.ts...'); +for (const member of MEMBERS) { + for (const roleId of member.memberOf) { + if (!allRoleIds.has(roleId)) { console.error( - `ERROR: Member "${member.github || member.email}" references team "${teamKey}" which does not exist in groups.ts` + `ERROR: Member "${member.github || member.email}" references role "${roleId}" which does not exist in roles.ts` ); hasErrors = true; } } } -// Validate parent team references (memberOf in groups) -console.log('Validating parent team references in groups.ts...'); -for (const g of GROUPS) { - const group = g as Group; - if (!group.memberOf) continue; +// Validate parent role references in roles.ts +console.log('Validating parent role references in roles.ts...'); +for (const role of ROLES) { + if (!role.github?.parent) continue; - for (const parentTeam of group.memberOf) { - if (!allGroupNames.has(parentTeam)) { - console.error( - `ERROR: Group "${group.name}" has parent "${parentTeam}" which does not exist in groups.ts` - ); - hasErrors = true; - } + const parentRole = roleLookup.get(role.github.parent); + if (!parentRole) { + console.error( + `ERROR: Role "${role.id}" has parent "${role.github.parent}" which does not exist in roles.ts` + ); + hasErrors = true; + } else if (!parentRole.github) { + console.error( + `ERROR: Role "${role.id}" has parent "${role.github.parent}" which does not have GitHub config` + ); + hasErrors = true; } } @@ -77,6 +78,6 @@ if (hasErrors) { console.error('\nValidation failed! Please fix the errors above.'); process.exit(1); } else { - console.log('\nAll team references are valid.'); + console.log('\nAll references are valid.'); process.exit(0); } diff --git a/src/config/groups.ts b/src/config/groups.ts deleted file mode 100644 index 72c15528..00000000 --- a/src/config/groups.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { defineGroups } from './utils'; - -// NOTE: For GitHub teams, only the first memberOf will be used as the parent team. -// GitHub only supports one parent team per team. -// -// Email groups (isEmailGroup: true) accept emails from anyone (including external users) -// and notify group members for each email. -// -// Groups are created on all platforms (GitHub and Google) by default. -// To limit a group to specific platforms, set the onlyOnPlatforms array (e.g., onlyOnPlatforms: ['google']). -export const GROUPS = defineGroups([ - // MCP Organization Structure - { - name: 'steering-committee', - description: 'MCP Steering Committee', - onlyOnPlatforms: ['github'], - }, - { - name: 'core-maintainers', - description: 'Core maintainers', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'moderators', - description: 'Community moderators', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'docs-maintaners', - description: 'MCP docs maintainers', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'inspector-maintainers', - description: 'MCP Inspector maintainers', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'mcpb-maintainers', - description: 'MCPB (Model Context Protocol Bundle) maintainers', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - - // SDK Maintainers - { - name: 'sdk-maintainers', - description: 'Authors and maintainers of official MCP SDKs', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'csharp-sdk', - description: 'Official C# SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'go-sdk', - description: 'The Go SDK Team', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'java-sdk', - description: 'Official Java SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'kotlin-sdk', - description: 'Official Kotlin SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'php-sdk', - description: 'Official PHP SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'python-sdk', - description: 'Official Python SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'python-sdk-auth', - description: 'Auth related owners', - memberOf: ['python-sdk'], - onlyOnPlatforms: ['github'], - }, - { - name: 'ruby-sdk', - description: 'Official Ruby SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'rust-sdk', - description: 'Official Rust SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'swift-sdk', - description: 'Official Swift SDK maintainers', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'typescript-sdk', - description: 'Official TypeScript SDK', - memberOf: ['sdk-maintainers'], - onlyOnPlatforms: ['github'], - }, - { - name: 'typescript-sdk-auth', - description: 'Code owners for auth in Typescript SDK', - memberOf: ['typescript-sdk'], - onlyOnPlatforms: ['github'], - }, - - // Working Groups - { - name: 'working-groups', - description: 'MCP Working Groups', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'auth-wg', - description: 'Authentication Working Group', - memberOf: ['working-groups'], - onlyOnPlatforms: ['github'], - }, - { - name: 'registry-wg', - description: 'Official registry builders and maintainers', - memberOf: ['working-groups'], - }, - { - name: 'security-wg', - description: 'Security Working Group', - memberOf: ['working-groups'], - onlyOnPlatforms: ['github'], - }, - { - name: 'transport-wg', - description: 'Transport Working Group', - memberOf: ['working-groups'], - onlyOnPlatforms: ['github'], - }, - - // Interest Groups - { - name: 'interest-groups', - description: 'Interest Groups', - memberOf: ['steering-committee'], - onlyOnPlatforms: ['github'], - }, - { - name: 'ig-financial-services', - description: 'Financial Services Interest Group', - memberOf: ['interest-groups'], - onlyOnPlatforms: ['github'], - }, - - // Email-only groups - { - name: 'antitrust', - description: 'Antitrust compliance contacts', - isEmailGroup: true, - onlyOnPlatforms: ['google'], - }, - { - name: 'catch-all', - description: 'Catch-all email group', - isEmailGroup: true, - onlyOnPlatforms: ['google'], - }, -] as const); diff --git a/src/config/repoAccess.ts b/src/config/repoAccess.ts index 054429f8..3434d954 100644 --- a/src/config/repoAccess.ts +++ b/src/config/repoAccess.ts @@ -21,7 +21,7 @@ export const REPOSITORY_ACCESS: RepositoryAccess[] = [ { team: 'auth-wg', permission: 'push' }, { team: 'core-maintainers', permission: 'maintain' }, { team: 'csharp-sdk', permission: 'push' }, - { team: 'docs-maintaners', permission: 'push' }, + { team: 'docs-maintainers', permission: 'push' }, { team: 'go-sdk', permission: 'push' }, { team: 'ig-financial-services', permission: 'push' }, { team: 'interest-groups', permission: 'push' }, @@ -51,7 +51,7 @@ export const REPOSITORY_ACCESS: RepositoryAccess[] = [ { team: 'auth-wg', permission: 'triage' }, { team: 'core-maintainers', permission: 'maintain' }, { team: 'csharp-sdk', permission: 'triage' }, - { team: 'docs-maintaners', permission: 'triage' }, + { team: 'docs-maintainers', permission: 'triage' }, { team: 'go-sdk', permission: 'triage' }, { team: 'ig-financial-services', permission: 'triage' }, { team: 'interest-groups', permission: 'triage' }, @@ -113,7 +113,7 @@ export const REPOSITORY_ACCESS: RepositoryAccess[] = [ { team: 'auth-wg', permission: 'push' }, { team: 'core-maintainers', permission: 'maintain' }, { team: 'csharp-sdk', permission: 'triage' }, - { team: 'docs-maintaners', permission: 'push' }, + { team: 'docs-maintainers', permission: 'push' }, { team: 'go-sdk', permission: 'triage' }, { team: 'ig-financial-services', permission: 'triage' }, { team: 'interest-groups', permission: 'triage' }, @@ -143,7 +143,7 @@ export const REPOSITORY_ACCESS: RepositoryAccess[] = [ { team: 'auth-wg', permission: 'push' }, { team: 'core-maintainers', permission: 'maintain' }, { team: 'csharp-sdk', permission: 'push' }, - { team: 'docs-maintaners', permission: 'push' }, + { team: 'docs-maintainers', permission: 'push' }, { team: 'go-sdk', permission: 'push' }, { team: 'ig-financial-services', permission: 'push' }, { team: 'interest-groups', permission: 'push' }, @@ -176,7 +176,7 @@ export const REPOSITORY_ACCESS: RepositoryAccess[] = [ { team: 'auth-wg', permission: 'push' }, { team: 'core-maintainers', permission: 'admin' }, { team: 'csharp-sdk', permission: 'push' }, - { team: 'docs-maintaners', permission: 'push' }, + { team: 'docs-maintainers', permission: 'push' }, { team: 'go-sdk', permission: 'push' }, { team: 'java-sdk', permission: 'push' }, { team: 'kotlin-sdk', permission: 'push' }, diff --git a/src/config/roleIds.ts b/src/config/roleIds.ts new file mode 100644 index 00000000..2170c636 --- /dev/null +++ b/src/config/roleIds.ts @@ -0,0 +1,75 @@ +/** + * Role ID constants for type-safe role references. + * Using constants prevents typos and enables autocomplete. + */ +export const ROLE_IDS = { + // =================== + // Organization Structure + // =================== + STEERING_COMMITTEE: 'steering-committee', + CORE_MAINTAINERS: 'core-maintainers', + LEAD_MAINTAINERS: 'lead-maintainers', + MODERATORS: 'moderators', + ADMINISTRATORS: 'administrators', // Discord only + + // =================== + // Maintainer Groups + // =================== + MAINTAINERS: 'maintainers', + DOCS_MAINTAINERS: 'docs-maintainers', + INSPECTOR_MAINTAINERS: 'inspector-maintainers', + MCPB_MAINTAINERS: 'mcpb-maintainers', + REFERENCE_SERVERS_MAINTAINERS: 'reference-servers-maintainers', + REGISTRY_MAINTAINERS: 'registry-maintainers', + USE_MCP_MAINTAINERS: 'use-mcp-maintainers', + + // =================== + // SDK Maintainers + // =================== + SDK_MAINTAINERS: 'sdk-maintainers', + CSHARP_SDK: 'csharp-sdk', + GO_SDK: 'go-sdk', + JAVA_SDK: 'java-sdk', + KOTLIN_SDK: 'kotlin-sdk', + PHP_SDK: 'php-sdk', + PYTHON_SDK: 'python-sdk', + PYTHON_SDK_AUTH: 'python-sdk-auth', // GitHub only (CODEOWNERS) + RUBY_SDK: 'ruby-sdk', + RUST_SDK: 'rust-sdk', + SWIFT_SDK: 'swift-sdk', + TYPESCRIPT_SDK: 'typescript-sdk', + TYPESCRIPT_SDK_AUTH: 'typescript-sdk-auth', // GitHub only (CODEOWNERS) + + // =================== + // Working Groups + // =================== + WORKING_GROUPS: 'working-groups', + AUTH_WG: 'auth-wg', + SECURITY_WG: 'security-wg', + SERVER_IDENTITY_WG: 'server-identity-wg', + TRANSPORT_WG: 'transport-wg', + + // =================== + // Interest Groups + // =================== + INTEREST_GROUPS: 'interest-groups', + AGENTS_IG: 'agents-ig', + AUTH_IG: 'auth-ig', + CLIENT_IMPLEMENTOR_IG: 'client-implementor-ig', + FINANCIAL_SERVICES_IG: 'financial-services-ig', + + // =================== + // Email Groups (Google only) + // =================== + ANTITRUST: 'antitrust', + CATCH_ALL: 'catch-all', +} as const; + +export type RoleId = (typeof ROLE_IDS)[keyof typeof ROLE_IDS]; + +/** + * Helper to check if a string is a valid RoleId at runtime. + */ +export function isValidRoleId(id: string): id is RoleId { + return Object.values(ROLE_IDS).includes(id as RoleId); +} diff --git a/src/config/roles.ts b/src/config/roles.ts new file mode 100644 index 00000000..42535d9b --- /dev/null +++ b/src/config/roles.ts @@ -0,0 +1,315 @@ +import { ROLE_IDS, type RoleId } from './roleIds'; + +/** + * GitHub team configuration + */ +export interface GitHubConfig { + /** Team name (usually matches role ID) */ + team: string; + /** Parent team role ID */ + parent?: RoleId; +} + +/** + * Discord role configuration + */ +export interface DiscordConfig { + /** Display name in Discord (can have spaces) */ + role: string; +} + +/** + * Google Workspace group configuration + */ +export interface GoogleConfig { + /** Group name (used as prefix for @modelcontextprotocol.io email) */ + group: string; + /** If true, accepts emails from anyone including external users */ + isEmailGroup?: boolean; +} + +/** + * Role definition with platform-specific configurations. + * A role only exists on platforms where it has a config key. + */ +export interface Role { + id: RoleId; + description: string; + github?: GitHubConfig; + discord?: DiscordConfig; + google?: GoogleConfig; +} + +/** + * All roles in the MCP organization. + * Each role specifies which platforms it exists on via presence of config keys. + */ +export const ROLES: readonly Role[] = [ + // =================== + // Organization Structure + // =================== + { + id: ROLE_IDS.STEERING_COMMITTEE, + description: 'MCP Steering Committee', + github: { team: 'steering-committee' }, + // No discord - this is a GitHub-only organizational container + }, + { + id: ROLE_IDS.ADMINISTRATORS, + description: 'Discord server administrators', + discord: { role: 'administrators' }, + // Discord only - no GitHub equivalent + }, + { + id: ROLE_IDS.LEAD_MAINTAINERS, + description: 'Lead maintainers with elevated responsibilities', + discord: { role: 'lead maintainers' }, + // Discord only for now - could add GitHub if needed + }, + { + id: ROLE_IDS.CORE_MAINTAINERS, + description: 'Core maintainers', + github: { team: 'core-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, + discord: { role: 'core maintainers' }, + }, + { + id: ROLE_IDS.MODERATORS, + description: 'Community moderators', + github: { team: 'moderators', parent: ROLE_IDS.STEERING_COMMITTEE }, + discord: { role: 'community moderators' }, + }, + + // =================== + // Maintainer Groups + // =================== + { + id: ROLE_IDS.MAINTAINERS, + description: 'General maintainers', + discord: { role: 'maintainers' }, + // Discord only - general maintainer role + }, + { + id: ROLE_IDS.DOCS_MAINTAINERS, + description: 'MCP docs maintainers', + github: { team: 'docs-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, + // No discord role for docs maintainers + }, + { + id: ROLE_IDS.INSPECTOR_MAINTAINERS, + description: 'MCP Inspector maintainers', + github: { team: 'inspector-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, + discord: { role: 'inspector maintainers' }, + }, + { + id: ROLE_IDS.MCPB_MAINTAINERS, + description: 'MCPB (Model Context Protocol Bundle) maintainers', + github: { team: 'mcpb-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, + // No discord role + }, + { + id: ROLE_IDS.REFERENCE_SERVERS_MAINTAINERS, + description: 'Reference servers maintainers', + discord: { role: 'reference servers maintainers' }, + // Discord only for now + }, + { + id: ROLE_IDS.REGISTRY_MAINTAINERS, + description: 'Official registry builders and maintainers', + github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS }, + discord: { role: 'registry maintainers' }, + google: { group: 'registry-wg' }, + }, + { + id: ROLE_IDS.USE_MCP_MAINTAINERS, + description: 'use-mcp maintainers', + discord: { role: 'use-mcp maintainers' }, + // Discord only + }, + + // =================== + // SDK Maintainers + // =================== + { + id: ROLE_IDS.SDK_MAINTAINERS, + description: 'Authors and maintainers of official MCP SDKs', + github: { team: 'sdk-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, + discord: { role: 'sdk maintainers' }, + }, + { + id: ROLE_IDS.CSHARP_SDK, + description: 'Official C# SDK maintainers', + github: { team: 'csharp-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'c# sdk maintainers' }, + }, + { + id: ROLE_IDS.GO_SDK, + description: 'The Go SDK Team', + github: { team: 'go-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'go sdk maintainers' }, + }, + { + id: ROLE_IDS.JAVA_SDK, + description: 'Official Java SDK maintainers', + github: { team: 'java-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'java sdk maintainers' }, + }, + { + id: ROLE_IDS.KOTLIN_SDK, + description: 'Official Kotlin SDK maintainers', + github: { team: 'kotlin-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'kotlin sdk maintainers' }, + }, + { + id: ROLE_IDS.PHP_SDK, + description: 'Official PHP SDK maintainers', + github: { team: 'php-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'php sdk maintainers' }, + }, + { + id: ROLE_IDS.PYTHON_SDK, + description: 'Official Python SDK maintainers', + github: { team: 'python-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'python sdk maintainers' }, + }, + { + id: ROLE_IDS.PYTHON_SDK_AUTH, + description: 'Python SDK auth code owners', + github: { team: 'python-sdk-auth', parent: ROLE_IDS.PYTHON_SDK }, + // GitHub only - for CODEOWNERS + }, + { + id: ROLE_IDS.RUBY_SDK, + description: 'Official Ruby SDK maintainers', + github: { team: 'ruby-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'ruby sdk maintainers' }, + }, + { + id: ROLE_IDS.RUST_SDK, + description: 'Official Rust SDK maintainers', + github: { team: 'rust-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'rust sdk maintainers' }, + }, + { + id: ROLE_IDS.SWIFT_SDK, + description: 'Official Swift SDK maintainers', + github: { team: 'swift-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'swift sdk maintainers' }, + }, + { + id: ROLE_IDS.TYPESCRIPT_SDK, + description: 'Official TypeScript SDK', + github: { team: 'typescript-sdk', parent: ROLE_IDS.SDK_MAINTAINERS }, + discord: { role: 'typescript sdk maintainers' }, + }, + { + id: ROLE_IDS.TYPESCRIPT_SDK_AUTH, + description: 'Code owners for auth in Typescript SDK', + github: { team: 'typescript-sdk-auth', parent: ROLE_IDS.TYPESCRIPT_SDK }, + // GitHub only - for CODEOWNERS + }, + + // =================== + // Working Groups + // =================== + { + id: ROLE_IDS.WORKING_GROUPS, + description: 'MCP Working Groups', + github: { team: 'working-groups', parent: ROLE_IDS.STEERING_COMMITTEE }, + // No discord - organizational container + }, + { + id: ROLE_IDS.AUTH_WG, + description: 'Authentication Working Group', + github: { team: 'auth-wg', parent: ROLE_IDS.WORKING_GROUPS }, + // See AUTH_IG for Discord role + }, + { + id: ROLE_IDS.SECURITY_WG, + description: 'Security Working Group', + github: { team: 'security-wg', parent: ROLE_IDS.WORKING_GROUPS }, + // See interest group for Discord role + }, + { + id: ROLE_IDS.SERVER_IDENTITY_WG, + description: 'Server Identity Working Group', + discord: { role: 'server identity working group' }, + // Discord only for now + }, + { + id: ROLE_IDS.TRANSPORT_WG, + description: 'Transport Working Group', + github: { team: 'transport-wg', parent: ROLE_IDS.WORKING_GROUPS }, + discord: { role: 'transports working group' }, + }, + + // =================== + // Interest Groups + // =================== + { + id: ROLE_IDS.INTEREST_GROUPS, + description: 'Interest Groups', + github: { team: 'interest-groups', parent: ROLE_IDS.STEERING_COMMITTEE }, + // No discord - organizational container + }, + { + id: ROLE_IDS.AGENTS_IG, + description: 'Agents Interest Group', + discord: { role: 'agents interest group' }, + // Discord only + }, + { + id: ROLE_IDS.AUTH_IG, + description: 'Auth Interest Group', + discord: { role: 'auth interest group' }, + // Discord only - separate from AUTH_WG which is GitHub + }, + { + id: ROLE_IDS.CLIENT_IMPLEMENTOR_IG, + description: 'Client Implementor Interest Group', + discord: { role: 'client implementor interest group' }, + // Discord only + }, + { + id: ROLE_IDS.FINANCIAL_SERVICES_IG, + description: 'Financial Services Interest Group', + github: { team: 'ig-financial-services', parent: ROLE_IDS.INTEREST_GROUPS }, + discord: { role: 'financial services interest group' }, + }, + + // =================== + // Email Groups (Google only) + // =================== + { + id: ROLE_IDS.ANTITRUST, + description: 'Antitrust compliance contacts', + google: { group: 'antitrust', isEmailGroup: true }, + // Google only + }, + { + id: ROLE_IDS.CATCH_ALL, + description: 'Catch-all email group', + google: { group: 'catch-all', isEmailGroup: true }, + // Google only + }, +] as const; + +/** + * Get a role by ID + */ +export function getRole(id: RoleId): Role | undefined { + return ROLES.find((r) => r.id === id); +} + +/** + * Get all roles that exist on a specific platform + */ +export function getRolesForPlatform(platform: 'github' | 'discord' | 'google'): Role[] { + return ROLES.filter((r) => r[platform] !== undefined); +} + +/** + * Build a lookup map of roles by ID + */ +export function buildRoleLookup(): Map { + return new Map(ROLES.map((r) => [r.id, r])); +} diff --git a/src/config/users.ts b/src/config/users.ts index 169725a5..072613b3 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -1,85 +1,93 @@ import type { Member } from './utils'; +import { ROLE_IDS } from './roleIds'; export const MEMBERS: readonly Member[] = [ { github: '000-000-000-000-000', - memberOf: ['core-maintainers'], + memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, { github: 'CodeWithKyrian', - memberOf: ['php-sdk'], + memberOf: [ROLE_IDS.PHP_SDK], }, { github: 'D-McAdams', - memberOf: ['auth-wg'], + memberOf: [ROLE_IDS.AUTH_WG], }, { github: 'Kehrlann', - memberOf: ['java-sdk'], + memberOf: [ROLE_IDS.JAVA_SDK], }, { github: 'Kludex', - memberOf: ['python-sdk'], + memberOf: [ROLE_IDS.PYTHON_SDK], }, { github: 'Nyholm', - memberOf: ['php-sdk'], + memberOf: [ROLE_IDS.PHP_SDK], }, { github: 'Ololoshechkin', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'a-akimov', - memberOf: ['docs-maintaners'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS], }, { github: 'aaronpk', - memberOf: ['auth-wg'], + memberOf: [ROLE_IDS.AUTH_WG], }, { github: 'alexhancock', - memberOf: ['rust-sdk'], + memberOf: [ROLE_IDS.RUST_SDK], }, { github: 'an-dustin', - memberOf: ['security-wg'], + memberOf: [ROLE_IDS.SECURITY_WG], }, { github: 'ansaba', - memberOf: ['go-sdk'], + memberOf: [ROLE_IDS.GO_SDK], }, { github: 'asklar', - memberOf: ['mcpb-maintainers'], + memberOf: [ROLE_IDS.MCPB_MAINTAINERS], }, { github: 'atesgoral', - memberOf: ['ruby-sdk'], + memberOf: [ROLE_IDS.RUBY_SDK], }, { github: 'baxen', - memberOf: ['rust-sdk'], + memberOf: [ROLE_IDS.RUST_SDK], }, { github: 'bhosmer-ant', - memberOf: ['core-maintainers', 'docs-maintaners', 'moderators', 'python-sdk', 'typescript-sdk'], + discord: '1272295077074567242', + memberOf: [ + ROLE_IDS.CORE_MAINTAINERS, + ROLE_IDS.DOCS_MAINTAINERS, + ROLE_IDS.MODERATORS, + ROLE_IDS.PYTHON_SDK, + ROLE_IDS.TYPESCRIPT_SDK, + ], }, { github: 'carlpeaslee', - memberOf: ['swift-sdk'], + memberOf: [ROLE_IDS.SWIFT_SDK], }, { github: 'chemicL', - memberOf: ['java-sdk'], + memberOf: [ROLE_IDS.JAVA_SDK], }, { github: 'chr-hertel', - memberOf: ['php-sdk'], + memberOf: [ROLE_IDS.PHP_SDK], }, { github: 'cliffhall', - memberOf: ['docs-maintaners', 'inspector-maintainers', 'moderators'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.MODERATORS], }, { github: 'crondinini-ant', @@ -87,271 +95,278 @@ export const MEMBERS: readonly Member[] = [ }, { github: 'dend', - memberOf: ['csharp-sdk'], + memberOf: [ROLE_IDS.CSHARP_SDK], }, { github: 'devcrocod', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'domdomegg', email: 'adam@modelcontextprotocol.io', - memberOf: ['mcpb-maintainers', 'registry-wg'], + memberOf: [ROLE_IDS.MCPB_MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS], }, { github: 'dsp-ant', email: 'david@modelcontextprotocol.io', + discord: '166107790262272000', // Example Discord user ID - replace with real ID memberOf: [ - 'auth-wg', - 'core-maintainers', - 'docs-maintaners', - 'go-sdk', - 'ig-financial-services', - 'moderators', - 'php-sdk', - 'python-sdk', - 'security-wg', - 'transport-wg', - 'typescript-sdk', + ROLE_IDS.AUTH_WG, + ROLE_IDS.CORE_MAINTAINERS, + ROLE_IDS.DOCS_MAINTAINERS, + ROLE_IDS.GO_SDK, + ROLE_IDS.FINANCIAL_SERVICES_IG, + ROLE_IDS.MODERATORS, + ROLE_IDS.PHP_SDK, + ROLE_IDS.PYTHON_SDK, + ROLE_IDS.SECURITY_WG, + ROLE_IDS.TRANSPORT_WG, + ROLE_IDS.TYPESCRIPT_SDK, ], }, { github: 'e5l', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'eiriktsarpalis', - memberOf: ['csharp-sdk'], + memberOf: [ROLE_IDS.CSHARP_SDK], }, { github: 'evalstate', - memberOf: ['docs-maintaners', 'moderators'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.MODERATORS], }, { github: 'fabpot', - memberOf: ['php-sdk'], + memberOf: [ROLE_IDS.PHP_SDK], }, { github: 'felixrieseberg', - memberOf: ['mcpb-maintainers'], + memberOf: [ROLE_IDS.MCPB_MAINTAINERS], }, { github: 'felixweinberger', - memberOf: ['python-sdk', 'security-wg', 'typescript-sdk'], + memberOf: [ROLE_IDS.PYTHON_SDK, ROLE_IDS.SECURITY_WG, ROLE_IDS.TYPESCRIPT_SDK], }, { github: 'findleyr', - memberOf: ['go-sdk'], + memberOf: [ROLE_IDS.GO_SDK], }, { github: 'halter73', - memberOf: ['csharp-sdk'], + memberOf: [ROLE_IDS.CSHARP_SDK], }, { github: 'ignatov', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'ihrpr', - memberOf: ['docs-maintaners', 'python-sdk', 'typescript-sdk'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.PYTHON_SDK, ROLE_IDS.TYPESCRIPT_SDK], }, { github: 'jamadeo', - memberOf: ['rust-sdk'], + memberOf: [ROLE_IDS.RUST_SDK], }, { github: 'jba', - memberOf: ['go-sdk'], + memberOf: [ROLE_IDS.GO_SDK], }, { github: 'jenn-newton', - memberOf: ['security-wg'], + memberOf: [ROLE_IDS.SECURITY_WG], }, { github: 'jerome3o-anthropic', - memberOf: ['moderators'], + memberOf: [ROLE_IDS.MODERATORS], }, { github: 'joan-anthropic', - memberOf: ['mcpb-maintainers'], + memberOf: [ROLE_IDS.MCPB_MAINTAINERS], }, { github: 'jonathanhefner', - memberOf: ['docs-maintaners', 'moderators', 'ruby-sdk'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.MODERATORS, ROLE_IDS.RUBY_SDK], }, { github: 'jspahrsummers', email: 'justin@modelcontextprotocol.io', - memberOf: ['core-maintainers'], + memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, { github: 'KKonstantinov', - memberOf: ['inspector-maintainers', 'typescript-sdk'], + memberOf: [ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.TYPESCRIPT_SDK], }, { github: 'koic', - memberOf: ['ruby-sdk'], + memberOf: [ROLE_IDS.RUBY_SDK], }, { github: 'kpavlov', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'kurtisvg', - memberOf: ['core-maintainers', 'transport-wg'], + memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG], }, { github: 'localden', - memberOf: ['auth-wg', 'core-maintainers'], + memberOf: [ROLE_IDS.AUTH_WG, ROLE_IDS.CORE_MAINTAINERS], }, { github: 'maheshmurag', - memberOf: ['moderators'], + memberOf: [ROLE_IDS.MODERATORS], }, { github: 'markpollack', - memberOf: ['java-sdk'], + memberOf: [ROLE_IDS.JAVA_SDK], }, { github: 'marshallofsound', - memberOf: ['mcpb-maintainers'], + memberOf: [ROLE_IDS.MCPB_MAINTAINERS], }, { github: 'mattt', - memberOf: ['swift-sdk'], + memberOf: [ROLE_IDS.SWIFT_SDK], }, { github: 'maxisbey', - memberOf: ['python-sdk'], + memberOf: [ROLE_IDS.PYTHON_SDK], }, { github: 'mattzcarey', - memberOf: ['typescript-sdk'], + memberOf: [ROLE_IDS.TYPESCRIPT_SDK], }, { github: 'michaelneale', - memberOf: ['rust-sdk'], + memberOf: [ROLE_IDS.RUST_SDK], }, { github: 'movetz', - memberOf: ['swift-sdk'], + memberOf: [ROLE_IDS.SWIFT_SDK], }, { github: 'mikekistler', - memberOf: ['csharp-sdk'], + memberOf: [ROLE_IDS.CSHARP_SDK], }, { github: 'nickcoai', - memberOf: ['core-maintainers'], + memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, { github: 'nicolas-grekas', - memberOf: ['php-sdk'], + memberOf: [ROLE_IDS.PHP_SDK], }, { github: 'ochafik', - memberOf: ['python-sdk', 'python-sdk-auth', 'typescript-sdk', 'typescript-sdk-auth'], + memberOf: [ + ROLE_IDS.PYTHON_SDK, + ROLE_IDS.PYTHON_SDK_AUTH, + ROLE_IDS.TYPESCRIPT_SDK, + ROLE_IDS.TYPESCRIPT_SDK_AUTH, + ], }, { github: 'og-ant', - memberOf: ['security-wg'], + memberOf: [ROLE_IDS.SECURITY_WG], }, { github: 'olaservo', - memberOf: ['docs-maintaners', 'inspector-maintainers', 'moderators'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.MODERATORS], }, { github: 'pcarleton', memberOf: [ - 'core-maintainers', - 'python-sdk', - 'python-sdk-auth', - 'typescript-sdk', - 'typescript-sdk-auth', - 'auth-wg', + ROLE_IDS.CORE_MAINTAINERS, + ROLE_IDS.PYTHON_SDK, + ROLE_IDS.PYTHON_SDK_AUTH, + ROLE_IDS.TYPESCRIPT_SDK, + ROLE_IDS.TYPESCRIPT_SDK_AUTH, + ROLE_IDS.AUTH_WG, ], }, { github: 'pederhp', - memberOf: ['ig-financial-services'], + memberOf: [ROLE_IDS.FINANCIAL_SERVICES_IG], }, { github: 'petery-ant', - memberOf: ['security-wg'], + memberOf: [ROLE_IDS.SECURITY_WG], }, { github: 'pronskiy', - memberOf: ['php-sdk'], + memberOf: [ROLE_IDS.PHP_SDK], }, { github: 'pwwpche', - memberOf: ['core-maintainers'], + memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, { github: 'rdimitrov', email: 'radoslav@modelcontextprotocol.io', - memberOf: ['registry-wg'], + memberOf: [ROLE_IDS.REGISTRY_MAINTAINERS], }, { github: 'sambhav', - memberOf: ['ig-financial-services'], + memberOf: [ROLE_IDS.FINANCIAL_SERVICES_IG], }, { github: 'samthanawalla', - memberOf: ['go-sdk'], + memberOf: [ROLE_IDS.GO_SDK], }, { github: 'sdubov', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'stephentoub', - memberOf: ['csharp-sdk'], + memberOf: [ROLE_IDS.CSHARP_SDK], }, { github: 'stallent', - memberOf: ['swift-sdk'], + memberOf: [ROLE_IDS.SWIFT_SDK], }, { github: 'tadasant', email: 'tadas@modelcontextprotocol.io', - memberOf: ['moderators', 'registry-wg'], + memberOf: [ROLE_IDS.MODERATORS, ROLE_IDS.REGISTRY_MAINTAINERS, ROLE_IDS.ADMINISTRATORS], }, { github: 'tiginamaria', - memberOf: ['kotlin-sdk'], + memberOf: [ROLE_IDS.KOTLIN_SDK], }, { github: 'toby', email: 'toby@modelcontextprotocol.io', - memberOf: ['registry-wg'], + memberOf: [ROLE_IDS.REGISTRY_MAINTAINERS], }, { github: 'topherbullock', - memberOf: ['ruby-sdk'], + memberOf: [ROLE_IDS.RUBY_SDK], }, { github: 'tzolov', - memberOf: ['docs-maintaners', 'java-sdk'], + memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.JAVA_SDK], }, { email: 'adamj@anthropic.com', - memberOf: ['catch-all'], + memberOf: [ROLE_IDS.CATCH_ALL], }, { email: 'davidsp@anthropic.com', - memberOf: ['antitrust'], + memberOf: [ROLE_IDS.ANTITRUST], }, { email: 'mattsamuels@anthropic.com', - memberOf: ['antitrust'], + memberOf: [ROLE_IDS.ANTITRUST], }, { email: 'davideramian@anthropic.com', - memberOf: ['antitrust'], + memberOf: [ROLE_IDS.ANTITRUST], }, { github: 'caitiem20', - memberOf: ['core-maintainers'], + discord: '1425586366288494722', + memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, ] as const; diff --git a/src/config/utils.ts b/src/config/utils.ts index fd0112a5..782c3e9c 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -1,43 +1,71 @@ -import type { GROUPS } from './groups'; +import type { RoleId } from './roleIds'; +import type { Role } from './roles'; -function isValidGroupName(name: string): boolean { - return /^[a-z][a-z0-9-]*[a-z]$/.test(name); +/** + * A member of the MCP organization. + * Members are assigned to roles via memberOf, and the role definitions + * determine which platforms (GitHub, Discord, Google) they get access to. + */ +export interface Member { + /** GitHub username */ + github?: string; + /** Email address (for Google Workspace) */ + email?: string; + /** Discord user ID (snowflake) */ + discord?: string; + /** Roles this member belongs to */ + memberOf: readonly RoleId[]; } -export type Platform = 'github' | 'google'; - -export function defineGroups< - const T extends readonly { - name: Lowercase; - description: string; - memberOf?: readonly T[number]['name'][]; - isEmailGroup?: boolean; - onlyOnPlatforms?: readonly Platform[]; - }[], ->(groups: T) { - for (const group of groups) { - if (!isValidGroupName(group.name)) { - throw new Error( - `Invalid group name: ${group.name}. Must be lowercase alphanumeric with dashes, starting with a letter.` - ); +/** + * Sort roles by GitHub parent dependency (topological sort). + * Ensures parent teams are created before child teams. + * + * This is necessary because when creating GitHub teams, we need the parent + * team's ID. If we process roles in arbitrary order, a child team might be + * processed before its parent, resulting in undefined parentTeamId. + */ +export function sortRolesByGitHubDependency( + roles: readonly Role[], + roleLookup: Map +): Role[] { + const result: Role[] = []; + const visited = new Set(); + + function visit(role: Role): void { + if (visited.has(role.id)) return; + + // Only process roles with GitHub config + if (!role.github) { + visited.add(role.id); + return; } - } - return groups; -} + // Visit parent first if it exists and has GitHub config + if (role.github.parent) { + const parentRole = roleLookup.get(role.github.parent); + if (parentRole) { + visit(parentRole); + } + } -export type GroupKey = (typeof GROUPS)[number]['name']; + visited.add(role.id); + result.push(role); + } -export interface Group { - name: GroupKey; - description: string; - memberOf?: readonly GroupKey[]; - isEmailGroup?: boolean; - onlyOnPlatforms?: readonly Platform[]; -} + for (const role of roles) { + visit(role); + } -export interface Member { - github?: string; - email?: string; - memberOf: readonly GroupKey[]; + return result; } + +// Re-export for convenience +export { ROLE_IDS, type RoleId } from './roleIds'; +export { + ROLES, + type Role, + type GitHubConfig, + type DiscordConfig, + type GoogleConfig, +} from './roles'; diff --git a/src/discord.ts b/src/discord.ts new file mode 100644 index 00000000..a26f6ce4 --- /dev/null +++ b/src/discord.ts @@ -0,0 +1,418 @@ +import * as pulumi from '@pulumi/pulumi'; +import { ROLES, type Role, buildRoleLookup } from './config/roles'; +import { MEMBERS } from './config/users'; +import type { RoleId } from './config/roleIds'; + +const config = new pulumi.Config('discord'); +// Discord integration is optional - only enabled if botToken and guildId are configured +const DISCORD_BOT_TOKEN = config.getSecret('botToken'); +const DISCORD_GUILD_ID = config.get('guildId'); +const DISCORD_ENABLED = DISCORD_BOT_TOKEN !== undefined && DISCORD_GUILD_ID !== undefined; + +if (!DISCORD_ENABLED) { + pulumi.log.info('Discord integration disabled: botToken or guildId not configured'); +} + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; + +interface DiscordApiError { + code: number; + message: string; +} + +async function discordFetch( + token: string, + endpoint: string, + options: RequestInit = {} +): Promise { + const response = await fetch(`${DISCORD_API_BASE}${endpoint}`, { + ...options, + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const error = (await response.json()) as DiscordApiError; + throw new Error(`Discord API error: ${error.message} (code: ${error.code})`); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +// Discord API response types +interface DiscordRoleApiResponse { + id: string; + name: string; + position: number; + permissions: string; + managed: boolean; +} + +interface DiscordGuildMemberApiResponse { + roles: string[]; +} + +// Discord Role Dynamic Provider +interface DiscordRoleInputs { + guildId: string; + roleName: string; + token: string; +} + +interface DiscordRoleOutputs extends DiscordRoleInputs { + roleId: string; +} + +const discordRoleProvider: pulumi.dynamic.ResourceProvider = { + async create( + inputs: DiscordRoleInputs + ): Promise> { + const role = await discordFetch( + inputs.token, + `/guilds/${inputs.guildId}/roles`, + { + method: 'POST', + body: JSON.stringify({ + name: inputs.roleName, + permissions: '0', // No special permissions - roles are for organization only + mentionable: false, + hoist: false, + }), + } + ); + + return { + id: role.id, + outs: { + ...inputs, + roleId: role.id, + }, + }; + }, + + async read( + id: string, + props: DiscordRoleOutputs + ): Promise> { + try { + const roles = await discordFetch( + props.token, + `/guilds/${props.guildId}/roles` + ); + + const role = roles.find((r) => r.id === id); + if (!role) { + // Role was deleted externally + throw new Error(`Role ${id} not found`); + } + + return { + id, + props: { + ...props, + roleName: role.name, + roleId: role.id, + }, + }; + } catch { + throw new Error(`Failed to read role ${id}`); + } + }, + + async update( + id: string, + _olds: DiscordRoleOutputs, + news: DiscordRoleInputs + ): Promise> { + await discordFetch(news.token, `/guilds/${news.guildId}/roles/${id}`, { + method: 'PATCH', + body: JSON.stringify({ + name: news.roleName, + }), + }); + + return { + outs: { + ...news, + roleId: id, + }, + }; + }, + + async delete(id: string, props: DiscordRoleOutputs): Promise { + try { + await discordFetch(props.token, `/guilds/${props.guildId}/roles/${id}`, { + method: 'DELETE', + }); + } catch (error) { + // Ignore errors if role is already deleted + console.warn(`Failed to delete role ${id}: ${error}`); + } + }, +}; + +class DiscordRole extends pulumi.dynamic.Resource { + public readonly roleId!: pulumi.Output; + public readonly roleName!: pulumi.Output; + public readonly guildId!: pulumi.Output; + + constructor( + name: string, + args: { + guildId: pulumi.Input; + roleName: pulumi.Input; + token: pulumi.Input; + }, + opts?: pulumi.CustomResourceOptions + ) { + super( + discordRoleProvider, + name, + { + roleId: undefined, + ...args, + }, + opts + ); + } +} + +// Discord Member Role Sync Dynamic Provider +// This provider reconciles a user's roles to match exactly what's defined in config +// It adds missing roles AND removes extra roles (only for roles we manage) +interface DiscordMemberRoleSyncInputs { + guildId: string; + userId: string; + /** Role IDs that this user SHOULD have (managed roles only) */ + expectedRoleIds: string[]; + /** All role IDs that we manage (to know which ones to potentially remove) */ + managedRoleIds: string[]; + token: string; +} + +interface DiscordMemberRoleSyncOutputs extends DiscordMemberRoleSyncInputs { + /** Roles that were added during last sync */ + addedRoles: string[]; + /** Roles that were removed during last sync */ + removedRoles: string[]; +} + +async function syncMemberRoles( + inputs: DiscordMemberRoleSyncInputs +): Promise<{ addedRoles: string[]; removedRoles: string[] }> { + // Get the user's current roles + const member = await discordFetch( + inputs.token, + `/guilds/${inputs.guildId}/members/${inputs.userId}` + ); + + const currentRoles = new Set(member.roles); + const expectedRoles = new Set(inputs.expectedRoleIds); + const managedRoles = new Set(inputs.managedRoleIds); + + const addedRoles: string[] = []; + const removedRoles: string[] = []; + + // Add missing roles + for (const roleId of Array.from(expectedRoles)) { + if (!currentRoles.has(roleId)) { + await discordFetch( + inputs.token, + `/guilds/${inputs.guildId}/members/${inputs.userId}/roles/${roleId}`, + { method: 'PUT' } + ); + addedRoles.push(roleId); + } + } + + // Remove roles that the user has but shouldn't (only managed roles) + for (const roleId of Array.from(currentRoles)) { + if (managedRoles.has(roleId) && !expectedRoles.has(roleId)) { + await discordFetch( + inputs.token, + `/guilds/${inputs.guildId}/members/${inputs.userId}/roles/${roleId}`, + { method: 'DELETE' } + ); + removedRoles.push(roleId); + } + } + + return { addedRoles, removedRoles }; +} + +const discordMemberRoleSyncProvider: pulumi.dynamic.ResourceProvider = { + async create( + inputs: DiscordMemberRoleSyncInputs + ): Promise> { + const { addedRoles, removedRoles } = await syncMemberRoles(inputs); + + return { + id: inputs.userId, + outs: { + ...inputs, + addedRoles, + removedRoles, + }, + }; + }, + + async read( + id: string, + props: DiscordMemberRoleSyncOutputs + ): Promise> { + try { + const member = await discordFetch( + props.token, + `/guilds/${props.guildId}/members/${props.userId}` + ); + + const currentRoles = new Set(member.roles); + const expectedRoles = new Set(props.expectedRoleIds); + const managedRoles = new Set(props.managedRoleIds); + + // Check if roles are in sync (only considering managed roles) + const outOfSync = + Array.from(expectedRoles).some((r) => !currentRoles.has(r)) || + Array.from(currentRoles).some((r) => managedRoles.has(r) && !expectedRoles.has(r)); + + if (outOfSync) { + // Return current state but note it needs update + return { + id, + props: { + ...props, + addedRoles: [], + removedRoles: [], + }, + }; + } + + return { id, props }; + } catch { + throw new Error(`Failed to read member roles for ${id}`); + } + }, + + async update( + id: string, + _olds: DiscordMemberRoleSyncOutputs, + news: DiscordMemberRoleSyncInputs + ): Promise> { + const { addedRoles, removedRoles } = await syncMemberRoles(news); + + return { + outs: { + ...news, + addedRoles, + removedRoles, + }, + }; + }, + + async delete(id: string, props: DiscordMemberRoleSyncOutputs): Promise { + // When a user is removed from config, remove all their managed roles + for (const roleId of props.expectedRoleIds) { + try { + await discordFetch( + props.token, + `/guilds/${props.guildId}/members/${props.userId}/roles/${roleId}`, + { method: 'DELETE' } + ); + } catch (error) { + console.warn(`Failed to remove role ${roleId} from user ${id}: ${error}`); + } + } + }, +}; + +class DiscordMemberRoleSync extends pulumi.dynamic.Resource { + public readonly addedRoles!: pulumi.Output; + public readonly removedRoles!: pulumi.Output; + + constructor( + name: string, + args: { + guildId: pulumi.Input; + userId: pulumi.Input; + expectedRoleIds: pulumi.Input[]>; + managedRoleIds: pulumi.Input[]>; + token: pulumi.Input; + }, + opts?: pulumi.CustomResourceOptions + ) { + super( + discordMemberRoleSyncProvider, + name, + { + addedRoles: undefined, + removedRoles: undefined, + ...args, + }, + opts + ); + } +} + +const roleLookup = buildRoleLookup(); +// Discord roles keyed by Discord role name +const roles: Record = {}; + +// Only create Discord resources if Discord is enabled +if (DISCORD_ENABLED) { + // These are guaranteed to be defined when DISCORD_ENABLED is true + const guildId = DISCORD_GUILD_ID!; + const botToken = DISCORD_BOT_TOKEN!; + + // Create Discord roles for roles that have Discord config + ROLES.forEach((role: Role) => { + if (!role.discord) return; + + roles[role.discord.role] = new DiscordRole(`discord-role-${role.id}`, { + guildId, + roleName: role.discord.role, + token: botToken, + }); + }); + + // Collect all managed role IDs (roles that have Discord config) + const allManagedRoleIds = ROLES.filter((r) => r.discord).map( + (r) => roles[r.discord!.role].roleId + ); + + // Sync roles for each member + MEMBERS.forEach((member) => { + if (!member.discord) return; + + // Get the Discord role IDs this member should have + const expectedRoleIds = member.memberOf + .map((roleId: RoleId) => { + const role = roleLookup.get(roleId); + if (!role?.discord) return null; + return roles[role.discord.role].roleId; + }) + .filter((id): id is pulumi.Output => id !== null); + + // Create a sync resource for this member + new DiscordMemberRoleSync( + `discord-member-sync-${member.discord}`, + { + guildId, + userId: member.discord!, + expectedRoleIds, + managedRoleIds: allManagedRoleIds, + token: botToken, + }, + { dependsOn: Object.values(roles) } + ); + }); +} + +export { roles as discordRoles }; diff --git a/src/github.ts b/src/github.ts index d398bf63..3d6a2230 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,40 +1,61 @@ import * as github from '@pulumi/github'; -import { GROUPS } from './config/groups'; +import { ROLES, type Role, buildRoleLookup } from './config/roles'; import { REPOSITORY_ACCESS } from './config/repoAccess'; -import type { Group } from './config/utils'; import { MEMBERS } from './config/users'; +import { sortRolesByGitHubDependency } from './config/utils'; +import type { RoleId } from './config/roleIds'; +const roleLookup = buildRoleLookup(); +// Teams keyed by GitHub team name (matches repoAccess.ts references) const teams: Record = {}; -GROUPS.forEach((group: Group) => { - // Skip groups that don't include github in their platforms - if (group.onlyOnPlatforms && !group.onlyOnPlatforms.includes('github')) return; +// Sort roles so parent teams are created before child teams +const sortedRoles = sortRolesByGitHubDependency(ROLES, roleLookup); - teams[group.name] = new github.Team(group.name, { - name: group.name, - description: group.description + ' \n(Managed by github.com/modelcontextprotocol/access)', +// Create GitHub teams for roles that have GitHub config +sortedRoles.forEach((role: Role) => { + if (!role.github) return; + + // Resolve parent team ID if specified + // Parent is guaranteed to exist in `teams` due to topological sort + let parentTeamId: github.Team['id'] | undefined; + if (role.github.parent) { + const parentRole = roleLookup.get(role.github.parent); + if (parentRole?.github) { + parentTeamId = teams[parentRole.github.team]?.id; + } + } + + teams[role.github.team] = new github.Team(role.github.team, { + name: role.github.team, + description: role.description + ' \n(Managed by github.com/modelcontextprotocol/access)', privacy: 'closed', - parentTeamId: group.memberOf?.[0] ? teams[group.memberOf[0]].id : undefined, + parentTeamId, }); }); +// Create team memberships MEMBERS.forEach((member) => { if (!member.github) return; - member.memberOf.forEach((teamKey) => { - new github.TeamMembership(`${member.github}-${teamKey}`, { - teamId: teams[teamKey].id, + member.memberOf.forEach((roleId: RoleId) => { + const role = roleLookup.get(roleId); + if (!role?.github) return; // Role doesn't have GitHub config + + new github.TeamMembership(`${member.github}-${role.github.team}`, { + teamId: teams[role.github.team].id, username: member.github!, role: 'member', }); }); }); +// Configure repository access REPOSITORY_ACCESS.forEach((repo) => { new github.RepositoryCollaborators(`repo-${repo.repository}`, { repository: repo.repository, teams: repo.teams?.map((t) => ({ - teamId: teams[t.team].id, + teamId: teams[t.team]?.id, permission: t.permission, })), users: repo.users?.map((u) => ({ @@ -43,3 +64,5 @@ REPOSITORY_ACCESS.forEach((repo) => { })), }); }); + +export { teams as githubTeams }; diff --git a/src/google.ts b/src/google.ts index 96c7d7c8..f52499b5 100644 --- a/src/google.ts +++ b/src/google.ts @@ -1,22 +1,24 @@ import * as gworkspace from '@pulumi/googleworkspace'; -import { GROUPS } from './config/groups'; -import type { Group } from './config/utils'; +import { ROLES, type Role, buildRoleLookup } from './config/roles'; import { MEMBERS } from './config/users'; +import type { RoleId } from './config/roleIds'; +const roleLookup = buildRoleLookup(); +// Groups keyed by Google group name const groups: Record = {}; -GROUPS.forEach((group: Group) => { - // Skip groups that don't include google in their platforms - if (group.onlyOnPlatforms && !group.onlyOnPlatforms.includes('google')) return; +// Create Google groups for roles that have Google config +ROLES.forEach((role: Role) => { + if (!role.google) return; - groups[group.name] = new gworkspace.Group(group.name, { - email: `${group.name}@modelcontextprotocol.io`, - name: group.name, - description: group.description + ' \n(Managed by github.com/modelcontextprotocol/access)', + groups[role.google.group] = new gworkspace.Group(role.google.group, { + email: `${role.google.group}@modelcontextprotocol.io`, + name: role.google.group, + description: role.description + ' \n(Managed by github.com/modelcontextprotocol/access)', }); - new gworkspace.GroupSettings(group.name, { - email: groups[group.name].email, + new gworkspace.GroupSettings(role.google.group, { + email: groups[role.google.group].email, // Maximise visibility of group. It's visible in GitHub anyway whoCanViewMembership: 'ALL_IN_DOMAIN_CAN_VIEW', @@ -29,7 +31,7 @@ GROUPS.forEach((group: Group) => { // Email groups allow anyone (including externals) to post // Non-email groups are not intended as mailing lists, so use the most restrictive settings // whoCanViewGroup is badly named, but actually means 'Permissions to view group messages'. See https://developers.google.com/workspace/admin/groups-settings/v1/reference/groups - ...(group.isEmailGroup + ...(role.google.isEmailGroup ? { whoCanPostMessage: 'ANYONE_CAN_POST', whoCanContactOwner: 'ALL_OWNERS_CAN_CONTACT', @@ -41,30 +43,22 @@ GROUPS.forEach((group: Group) => { whoCanViewGroup: 'ALL_OWNERS_CAN_VIEW', }), }); - - group.memberOf?.forEach((parentGroupKey) => { - // Skip if parent group doesn't exist on Google (e.g., onlyOnPlatforms: ['github']) - if (!groups[parentGroupKey]) return; - - new gworkspace.GroupMember(`${group.name}-in-${parentGroupKey}`, { - groupId: groups[parentGroupKey].id, - email: groups[group.name].email, - role: 'MEMBER', - }); - }); }); +// Create group memberships for users MEMBERS.forEach((member) => { if (!member.email) return; - member.memberOf.forEach((teamKey) => { - // Skip if group doesn't exist on Google (e.g., onlyOnPlatforms: ['github']) - if (!groups[teamKey]) return; + member.memberOf.forEach((roleId: RoleId) => { + const role = roleLookup.get(roleId); + if (!role?.google) return; // Role doesn't have Google config - new gworkspace.GroupMember(`${member.email}-${teamKey}`, { - groupId: groups[teamKey].id, + new gworkspace.GroupMember(`${member.email}-${role.google.group}`, { + groupId: groups[role.google.group].id, email: member.email!, role: 'MEMBER', }); }); }); + +export { groups as googleGroups }; diff --git a/src/index.ts b/src/index.ts index 4fe06f18..c72d079b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './github'; export * from './google'; +export * from './discord';