- Add discordImplies field to Role interface for Discord-specific role hierarchy - Add expandDiscordRoles() function to traverse both GitHub parent chain and discordImplies relationships when syncing Discord roles - SDK_MAINTAINERS now implies MAINTAINERS for Discord This ensures that users with SDK roles (GO_SDK, PYTHON_SDK, etc.) automatically get the 'maintainers (synced)' Discord role through: GO_SDK -> SDK_MAINTAINERS (via GitHub parent) -> MAINTAINERS (via discordImplies) Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 5 Claude-Permission-Prompts: 1 Claude-Escapes: 0
486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
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[];
|
|
/** True if the member was not found on the Discord server */
|
|
memberNotFound: boolean;
|
|
}
|
|
|
|
async function syncMemberRoles(
|
|
inputs: DiscordMemberRoleSyncInputs
|
|
): Promise<{ addedRoles: string[]; removedRoles: string[]; memberNotFound: boolean }> {
|
|
// Get the user's current roles
|
|
let member: DiscordGuildMemberApiResponse;
|
|
try {
|
|
member = await discordFetch<DiscordGuildMemberApiResponse>(
|
|
inputs.token,
|
|
`/guilds/${inputs.guildId}/members/${inputs.userId}`
|
|
);
|
|
} catch (error) {
|
|
// If the member isn't on the server, skip gracefully
|
|
if (error instanceof Error && error.message.includes('code: 10007')) {
|
|
console.warn(
|
|
`Discord member ${inputs.userId} not found on server - skipping role sync. ` +
|
|
`They may have left the server or the Discord ID may be incorrect.`
|
|
);
|
|
return { addedRoles: [], removedRoles: [], memberNotFound: true };
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
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, memberNotFound: false };
|
|
}
|
|
|
|
const discordMemberRoleSyncProvider: pulumi.dynamic.ResourceProvider = {
|
|
async create(
|
|
inputs: DiscordMemberRoleSyncInputs
|
|
): Promise<pulumi.dynamic.CreateResult<DiscordMemberRoleSyncOutputs>> {
|
|
const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(inputs);
|
|
|
|
return {
|
|
id: inputs.userId,
|
|
outs: {
|
|
...inputs,
|
|
addedRoles,
|
|
removedRoles,
|
|
memberNotFound,
|
|
},
|
|
};
|
|
},
|
|
|
|
async read(
|
|
id: string,
|
|
props: DiscordMemberRoleSyncOutputs
|
|
): Promise<pulumi.dynamic.ReadResult<DiscordMemberRoleSyncOutputs>> {
|
|
let member: DiscordGuildMemberApiResponse;
|
|
try {
|
|
member = await discordFetch<DiscordGuildMemberApiResponse>(
|
|
props.token,
|
|
`/guilds/${props.guildId}/members/${props.userId}`
|
|
);
|
|
} catch (error) {
|
|
// If the member isn't on the server, return state indicating they're not found
|
|
if (error instanceof Error && error.message.includes('code: 10007')) {
|
|
return {
|
|
id,
|
|
props: {
|
|
...props,
|
|
addedRoles: [],
|
|
removedRoles: [],
|
|
memberNotFound: true,
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Failed to read member roles for ${id}`);
|
|
}
|
|
|
|
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: [],
|
|
memberNotFound: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { id, props: { ...props, memberNotFound: false } };
|
|
},
|
|
|
|
async update(
|
|
id: string,
|
|
_olds: DiscordMemberRoleSyncOutputs,
|
|
news: DiscordMemberRoleSyncInputs
|
|
): Promise<pulumi.dynamic.UpdateResult<DiscordMemberRoleSyncOutputs>> {
|
|
const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(news);
|
|
|
|
return {
|
|
outs: {
|
|
...news,
|
|
addedRoles,
|
|
removedRoles,
|
|
memberNotFound,
|
|
},
|
|
};
|
|
},
|
|
|
|
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[]>;
|
|
public readonly memberNotFound!: pulumi.Output<boolean>;
|
|
|
|
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,
|
|
memberNotFound: undefined,
|
|
...args,
|
|
},
|
|
opts
|
|
);
|
|
}
|
|
}
|
|
|
|
const roleLookup = buildRoleLookup();
|
|
// Discord roles keyed by Discord role name
|
|
const roles: Record<string, DiscordRole> = {};
|
|
|
|
/**
|
|
* Expand a set of role IDs to include all implied Discord roles.
|
|
* This traverses:
|
|
* 1. GitHub parent relationships (e.g., GO_SDK -> SDK_MAINTAINERS)
|
|
* 2. discordImplies relationships (e.g., SDK_MAINTAINERS -> MAINTAINERS)
|
|
*/
|
|
function expandDiscordRoles(roleIds: readonly RoleId[]): Set<RoleId> {
|
|
const expanded = new Set<RoleId>();
|
|
const toProcess = [...roleIds];
|
|
|
|
while (toProcess.length > 0) {
|
|
const roleId = toProcess.pop()!;
|
|
if (expanded.has(roleId)) continue;
|
|
expanded.add(roleId);
|
|
|
|
const role = roleLookup.get(roleId);
|
|
if (!role) continue;
|
|
|
|
// Follow GitHub parent relationship
|
|
if (role.github?.parent) {
|
|
toProcess.push(role.github.parent);
|
|
}
|
|
|
|
// Follow discordImplies relationships
|
|
if (role.discordImplies) {
|
|
toProcess.push(...role.discordImplies);
|
|
}
|
|
}
|
|
|
|
return expanded;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Expand roles to include parents and implied roles
|
|
const expandedRoleIds = expandDiscordRoles(member.memberOf);
|
|
|
|
// Get the Discord role IDs this member should have
|
|
const expectedRoleIds = Array.from(expandedRoleIds)
|
|
.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 };
|