From e36544e3afe55b1f2eb3a8ca162520fddc16d94c Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 15 Jan 2026 22:32:35 +0000 Subject: [PATCH] Fix ext-apps: mcp-apps-wg should have push, not admin --- scripts/audit-discord-roles.ts | 220 +++++++++++++++++++++++++++++++++ src/config/repoAccess.ts | 2 +- src/config/roleIds.ts | 1 + src/config/roles.ts | 6 + src/config/users.ts | 11 +- 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 scripts/audit-discord-roles.ts diff --git a/scripts/audit-discord-roles.ts b/scripts/audit-discord-roles.ts new file mode 100644 index 0000000..c4115c0 --- /dev/null +++ b/scripts/audit-discord-roles.ts @@ -0,0 +1,220 @@ +/** + * Script to audit Discord roles against the repo configuration. + * + * For each user defined in the repo with a Discord ID, this script: + * 1. Gets their actual Discord roles + * 2. Compares against expected roles based on their memberOf + * 3. Reports discrepancies + * + * Run with: npx ts-node scripts/audit-discord-roles.ts + */ + +import { MEMBERS } from '../src/config/users'; +import { ROLES, getRole } from '../src/config/roles'; +import type { RoleId } from '../src/config/roleIds'; + +const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN || ''; +const GUILD_ID = process.env.DISCORD_GUILD_ID || ''; + +interface DiscordRole { + id: string; + name: string; +} + +interface DiscordMember { + user: { id: string; username: string; global_name: string }; + roles: string[]; +} + +// Build role name -> id lookup from Discord +const discordRoleNameToId = new Map(); +const discordRoleIdToName = new Map(); + +// Build expected role name -> RoleId lookup from config +const syncedRoleNameToRoleId = new Map(); + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { Authorization: `Bot ${BOT_TOKEN}` }, + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Discord API error: ${response.status} ${error}`); + } + return response.json(); +} + +async function getDiscordRoles(): Promise { + return fetchJson(`https://discord.com/api/v10/guilds/${GUILD_ID}/roles`); +} + +async function getDiscordMember(discordId: string): Promise { + try { + return await fetchJson(`https://discord.com/api/v10/guilds/${GUILD_ID}/members/${discordId}`); + } catch (e) { + return null; + } +} + +/** + * Expand role IDs to include all Discord-implied roles + * (GitHub parent traversal + discordImplies) + */ +function expandDiscordRoles(roleIds: readonly RoleId[]): Set { + const expanded = new Set(); + const queue = [...roleIds]; + + while (queue.length > 0) { + const roleId = queue.shift()!; + if (expanded.has(roleId)) continue; + + const role = getRole(roleId); + if (!role) continue; + + expanded.add(roleId); + + // Add parent role (GitHub hierarchy) + if (role.github?.parent) { + queue.push(role.github.parent); + } + + // Add discord-implied roles + if (role.discordImplies) { + queue.push(...role.discordImplies); + } + } + + return expanded; +} + +async function main() { + if (!BOT_TOKEN || !GUILD_ID) { + console.error('Set DISCORD_BOT_TOKEN and DISCORD_GUILD_ID environment variables'); + process.exit(1); + } + + console.log('Fetching Discord roles...'); + const discordRoles = await getDiscordRoles(); + + // Build role lookups + for (const role of discordRoles) { + discordRoleNameToId.set(role.name, role.id); + discordRoleIdToName.set(role.id, role.name); + } + + // Build synced role name -> RoleId lookup + for (const role of ROLES) { + if (role.discord?.role) { + syncedRoleNameToRoleId.set(role.discord.role, role.id); + } + } + + // Map manual role names to their synced equivalents + const manualToSynced = new Map(); + for (const role of discordRoles) { + const syncedName = `${role.name} (synced)`; + if (discordRoleNameToId.has(syncedName)) { + manualToSynced.set(role.name, syncedName); + } + } + + console.log('\n=== Discord Roles Summary ==='); + console.log(`Total roles: ${discordRoles.length}`); + console.log(`Synced roles: ${discordRoles.filter(r => r.name.endsWith('(synced)')).length}`); + console.log(`Manual roles with synced equivalent: ${manualToSynced.size}`); + + console.log('\n=== Auditing Users ===\n'); + + const issues: string[] = []; + const membersWithDiscord = MEMBERS.filter(m => m.discord); + + for (const member of membersWithDiscord) { + const discordMember = await getDiscordMember(member.discord!); + if (!discordMember) { + issues.push(`āŒ ${member.github || member.email}: Discord ID ${member.discord} not found in guild`); + continue; + } + + const username = discordMember.user.global_name || discordMember.user.username; + + // Get actual role names + const actualRoleNames = new Set( + discordMember.roles.map(id => discordRoleIdToName.get(id) || id) + ); + + // Get expected synced role names based on memberOf + const expandedRoleIds = expandDiscordRoles(member.memberOf); + const expectedSyncedRoles = new Set(); + const expectedManualRoles = new Set(); + + for (const roleId of expandedRoleIds) { + const role = getRole(roleId); + if (role?.discord?.role) { + expectedSyncedRoles.add(role.discord.role); + // Also expect the manual equivalent if it exists + const manualName = role.discord.role.replace(' (synced)', ''); + if (discordRoleNameToId.has(manualName)) { + expectedManualRoles.add(manualName); + } + } + } + + // Check for missing synced roles + const missingSyncedRoles: string[] = []; + for (const expectedRole of expectedSyncedRoles) { + if (!actualRoleNames.has(expectedRole)) { + missingSyncedRoles.push(expectedRole); + } + } + + // Check for manual roles without synced equivalent + const manualWithoutSynced: string[] = []; + for (const roleName of actualRoleNames) { + if (roleName.endsWith('(synced)')) continue; + if (roleName === '@everyone') continue; + + const syncedEquivalent = `${roleName} (synced)`; + if (discordRoleNameToId.has(syncedEquivalent)) { + // This is a manual role that has a synced equivalent + if (!actualRoleNames.has(syncedEquivalent)) { + manualWithoutSynced.push(`${roleName} (has no ${syncedEquivalent})`); + } + } + } + + if (missingSyncedRoles.length > 0 || manualWithoutSynced.length > 0) { + console.log(`\nšŸ‘¤ ${username} (${member.github || member.email})`); + console.log(` Discord ID: ${member.discord}`); + + if (missingSyncedRoles.length > 0) { + console.log(` āŒ Missing synced roles:`); + for (const role of missingSyncedRoles) { + console.log(` - ${role}`); + issues.push(`${username}: Missing ${role}`); + } + } + + if (manualWithoutSynced.length > 0) { + console.log(` āš ļø Manual roles without synced equivalent:`); + for (const role of manualWithoutSynced) { + console.log(` - ${role}`); + issues.push(`${username}: ${role}`); + } + } + } else { + console.log(`āœ… ${username} (${member.github || member.email})`); + } + } + + console.log('\n\n=== Summary ==='); + if (issues.length === 0) { + console.log('āœ… All users have matching roles!'); + } else { + console.log(`āŒ Found ${issues.length} issues:\n`); + for (const issue of issues) { + console.log(` - ${issue}`); + } + } +} + +main().catch(console.error); diff --git a/src/config/repoAccess.ts b/src/config/repoAccess.ts index 874b24d..69c6939 100644 --- a/src/config/repoAccess.ts +++ b/src/config/repoAccess.ts @@ -313,7 +313,7 @@ export const REPOSITORY_ACCESS: RepositoryAccess[] = [ teams: [ { team: 'core-maintainers', permission: 'push' }, { team: 'moderators', permission: 'triage' }, - { team: 'mcp-apps-wg', permission: 'admin' }, + { team: 'mcp-apps-wg', permission: 'push' }, { team: 'mcp-apps-sdk', permission: 'admin' }, ], users: [ diff --git a/src/config/roleIds.ts b/src/config/roleIds.ts index b560bc4..d54be78 100644 --- a/src/config/roleIds.ts +++ b/src/config/roleIds.ts @@ -11,6 +11,7 @@ export const ROLE_IDS = { LEAD_MAINTAINERS: 'lead-maintainers', MODERATORS: 'moderators', ADMINISTRATORS: 'administrators', // Discord only + COMMUNITY_MANAGERS: 'community-managers', // Discord only // =================== // Maintainer Groups diff --git a/src/config/roles.ts b/src/config/roles.ts index a97d44b..2f349da 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -66,6 +66,12 @@ export const ROLES: readonly Role[] = [ discord: { role: 'administrators (synced)' }, // Discord only - no GitHub equivalent }, + { + id: ROLE_IDS.COMMUNITY_MANAGERS, + description: 'Discord community managers', + discord: { role: 'community managers (synced)' }, + // Discord only - no GitHub equivalent + }, { id: ROLE_IDS.LEAD_MAINTAINERS, description: 'Lead core maintainers', diff --git a/src/config/users.ts b/src/config/users.ts index c76a8c9..36f609e 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -111,6 +111,7 @@ export const MEMBERS: readonly Member[] = [ email: 'cliff@futurescale.com', discord: '501498061965754380', memberOf: [ + ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.MAINTAINERS, ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS, @@ -163,7 +164,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'evalstate', discord: '779268016121577492', - memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.MODERATORS], + memberOf: [ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.MODERATORS], }, { github: 'fabpot', @@ -271,6 +272,11 @@ export const MEMBERS: readonly Member[] = [ discord: '936242781733654588', memberOf: [ROLE_IDS.GO_SDK], }, + { + github: 'macoughl', + discord: '740279257548193803', + memberOf: [ROLE_IDS.COMMUNITY_MANAGERS], + }, { github: 'maheshmurag', memberOf: [ROLE_IDS.MODERATORS], @@ -334,7 +340,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'olaservo', discord: '1079841769946095620', - memberOf: [ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.MODERATORS], + memberOf: [ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.MODERATORS], }, { github: 'pcarleton', @@ -399,6 +405,7 @@ export const MEMBERS: readonly Member[] = [ discord: '400092503677599754', email: 'tadas@modelcontextprotocol.io', memberOf: [ + ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.MODERATORS, ROLE_IDS.MAINTAINERS, ROLE_IDS.WORKING_GROUPS,