Add Discord role sync with role-centric architecture (#22)

* Add Discord role sync with role-centric architecture

This refactors the access management to use a role-centric model where
each role explicitly defines which platforms it exists on (GitHub,
Discord, Google).

Key changes:
- Add Discord role sync via Pulumi Dynamic Provider
- Replace groups.ts with roleIds.ts + roles.ts for type-safe role definitions
- Use ROLE_IDS constants to prevent typos in memberOf references
- Each role has platform-specific config (github/discord/google keys)
- Discord-only roles (e.g., administrators) and GitHub-only roles
  (e.g., typescript-sdk-auth) are now explicitly modeled
- Add PR preview workflow to show Pulumi changes before merge
- Add test-config.ts for config structure validation
- Fix typo: docs-maintaners -> docs-maintainers

Discord roles will match existing server roles:
- administrators, lead maintainers, core maintainers
- sdk maintainers, working groups, interest groups
- etc.

Requires new GitHub secrets:
- DISCORD_BOT_TOKEN
- DISCORD_GUILD_ID

* Fix GitHub team ordering and make Discord integration optional

- Add topological sort for GitHub teams to ensure parent teams are
  created before child teams, fixing the registry-wg parentTeamId issue
- Make Discord integration optional by using config.get() instead of
  config.require(), allowing CI preview to succeed without Discord secrets
- Add sortRolesByGitHubDependency() utility function in config/utils.ts

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=20%)
Claude-Steers: 2
Claude-Permission-Prompts: 1
Claude-Escapes: 0

* Reconcile Discord roles for listed users

Replace per-role-membership approach with per-user role sync that:
- Adds missing roles users should have
- Removes managed roles users shouldn't have
- Only touches roles defined in config (won't affect admin roles, etc.)

Users not in config remain untouched.

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 4
Claude-Permission-Prompts: 1
Claude-Escapes: 0

* Fix descriptions to match existing resources

* Cache Pulumi plugins and fix GITHUB_TOKEN for pulumi install

* Remove accidentally committed state backup

* Use github.token for pulumi install

* Fix formatting

Fix deploy: add pulumi login before config set

Fix deploy: set PULUMI_CONFIG_PASSPHRASE_FILE for config commands

Import existing docs-maintainers team (remove after deploy)

Remove docs-maintainers import workaround

Add docs-maintainers cleanup to state cleanup

Add pulumi refresh before up to sync state with reality

Rename Discord roles to add (synced) suffix

Cleanup Makefile and add lead-maintainers GitHub team
This commit is contained in:
David Soria Parra 2026-01-13 22:03:59 +00:00 • committed by David Soria Parra
commit f960494174
No known key found for this signature in database

418
src/discord.ts Normal file
View file

@ -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<T>(
token: string,
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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<T>;
}
// 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<pulumi.dynamic.CreateResult<DiscordRoleOutputs>> {
const role = await discordFetch<DiscordRoleApiResponse>(
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<pulumi.dynamic.ReadResult<DiscordRoleOutputs>> {
try {
const roles = await discordFetch<DiscordRoleApiResponse[]>(
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<pulumi.dynamic.UpdateResult<DiscordRoleOutputs>> {
await discordFetch<DiscordRoleApiResponse>(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<void> {
try {
await discordFetch<void>(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<string>;
public readonly roleName!: pulumi.Output<string>;
public readonly guildId!: pulumi.Output<string>;
constructor(
name: string,
args: {
guildId: pulumi.Input<string>;
roleName: pulumi.Input<string>;
token: pulumi.Input<string>;
},
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<DiscordGuildMemberApiResponse>(
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<void>(
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<void>(
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<pulumi.dynamic.CreateResult<DiscordMemberRoleSyncOutputs>> {
const { addedRoles, removedRoles } = await syncMemberRoles(inputs);
return {
id: inputs.userId,
outs: {
...inputs,
addedRoles,
removedRoles,
},
};
},
async read(
id: string,
props: DiscordMemberRoleSyncOutputs
): Promise<pulumi.dynamic.ReadResult<DiscordMemberRoleSyncOutputs>> {
try {
const member = await discordFetch<DiscordGuildMemberApiResponse>(
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<pulumi.dynamic.UpdateResult<DiscordMemberRoleSyncOutputs>> {
const { addedRoles, removedRoles } = await syncMemberRoles(news);
return {
outs: {
...news,
addedRoles,
removedRoles,
},
};
},
async delete(id: string, props: DiscordMemberRoleSyncOutputs): Promise<void> {
// When a user is removed from config, remove all their managed roles
for (const roleId of props.expectedRoleIds) {
try {
await discordFetch<void>(
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<string[]>;
public readonly removedRoles!: pulumi.Output<string[]>;
constructor(
name: string,
args: {
guildId: pulumi.Input<string>;
userId: pulumi.Input<string>;
expectedRoleIds: pulumi.Input<pulumi.Input<string>[]>;
managedRoleIds: pulumi.Input<pulumi.Input<string>[]>;
token: pulumi.Input<string>;
},
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<string, DiscordRole> = {};
// 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<string> => 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 };