Files
ephemere/python/cohort/remove_member.py
T
hikari ec58c9c843
CI / dependency-pin-check-typescript (push) Successful in 5s
CI / dependency-pin-check-python (push) Successful in 4s
CI / python (push) Successful in 9m28s
CI / typescript (push) Successful in 9m42s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
feat: reorganise bash scripts and add comprehensive documentation (#6)
## Summary

This PR completes the bash script restructuring and adds comprehensive documentation across all script categories.

### Bash Restructuring

- Moved cohort shell scripts (`remove_github_members.sh`, `update_github_teams.sh`) from `python/cohort/` into a new `bash/cohort/` directory
- Moved existing bash utilities (`add-keys-to-git.sh`, `fix-yubikey-perms.sh`, `list-yubikey-ssh-keys.sh`) into a new `bash/yubikey/` subdirectory
- Updated `run.sh` to support **Bash** as a third language option alongside TypeScript and Python
  - Bash scripts are run directly (no 1Password secret injection needed)
  - Category discovery and script listing works the same as for TS/Python
  - Removed dead "Root Scripts" logic that was no longer needed

### Documentation

Added `README.md` files for all script categories that were missing them:

- `bash/cohort/README.md` — cohort GitHub team management scripts
- `bash/yubikey/README.md` — YubiKey SSH key and permission utilities
- `typescript/src/crowdin/README.md` — Crowdin translation management scripts
- `typescript/src/discord/README.md` — Discord bot utility scripts
- `typescript/src/discourse/README.md` — Discourse forum management scripts
- `typescript/src/gitea/README.md` — Gitea bulk repository operation scripts
- `typescript/src/github/README.md` — GitHub API interaction scripts
- `typescript/src/music/README.md` — Music file metadata tools
- `typescript/src/s3/README.md` — S3-compatible object storage scripts
- `typescript/src/security/README.md` — Security analysis and reporting scripts
- `python/cohort/README.md` — Updated to remove moved shell scripts, fix usage commands

Also updated project-level docs:

- **`README.md`** — Corrected project structure, fixed running instructions (removed references to non-existent `make run-ts`/`make run-py` targets), added Bash prerequisites
- **`CLAUDE.md`** — Updated project overview, structure, development standards, and script-adding guides to reflect the current state of the project

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #6
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-23 20:18:41 -08:00

248 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Remove a member from the Spring 2026 Cohort.
This script:
1. Removes from team_assignments.json (so activity checker stops tracking them)
2. Removes from discord_to_github.json
3. Removes from GitHub teams
4. Removes Discord roles
5. Sends a message to the team channel announcing the removal
6. Outputs notes to add to COHORT_NOTES.md
Usage:
python remove_member.py <discord_id>
"""
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_API_BASE = "https://discord.com/api/v10"
DISCORD_GUILD_ID = "692816967895220344"
COHORT_ROLE_ID = "1464316447591985194"
TEXT_CHANNEL_IDS = {
"Jade Jasmine": "1464316501573107886",
"Crimson Dahlia": "1464316744909852682",
"Rose Camellia": "1464316751268286611",
"Amber Wisteria": "1464316761410113641",
"Ivory Orchid": "1464316770889240730",
"Teal Iris": "1464316776459407448",
"Peach Gardenia": "1464316785040953543",
"Violet Carnation": "1464316805261824032",
"Azure Lotus": "1464316814455472139",
"Coral Sunflower": "1464316819711066263",
"Indigo Tulip": "1464316826384072925",
"Scarlet Hydrangea": "1464316839306985506",
"Mint Narcissus": "1464316844251807952",
"Sage Marigold": "1464316850669093040",
}
class MemberRemover:
"""Handles the complete removal of a cohort member."""
def __init__(self, discord_id: str) -> None:
self.discord_id = discord_id
self.headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
self.session: aiohttp.ClientSession | None = None
self.github_username: str | None = None
self.team_name: str | None = None
self.role: str | None = None
self.team_role_id: str | None = None
async def __aenter__(self) -> "MemberRemover":
self.session = aiohttp.ClientSession()
return self
async def __aexit__(
self, exc_type: object, exc_val: object, exc_tb: object
) -> None:
if self.session:
await self.session.close()
def find_member_info(self) -> bool:
"""Find which team the member is on and their role."""
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
for team in teams:
if self.discord_id in team["leaders"]:
self.team_name = team["name"]
self.role = "leader"
return True
if self.discord_id in team["participants"]:
self.team_name = team["name"]
self.role = "participant"
return True
return False
def remove_from_team_assignments(self) -> None:
"""Remove member from team_assignments.json."""
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
for team in teams:
if self.discord_id in team["leaders"]:
team["leaders"].remove(self.discord_id)
print(
f"✅ Removed from {team['name']} leaders in team_assignments.json"
)
elif self.discord_id in team["participants"]:
team["participants"].remove(self.discord_id)
print(
f"✅ Removed from {team['name']} participants in team_assignments.json"
)
with open(DATA_DIR / "team_assignments.json", "w") as f:
json.dump(teams, f, indent=2)
def remove_from_discord_to_github(self) -> None:
"""Remove member from discord_to_github.json."""
with open(DATA_DIR / "discord_to_github.json") as f:
mappings = json.load(f)
if self.discord_id in mappings:
self.github_username = mappings[self.discord_id]
del mappings[self.discord_id]
with open(DATA_DIR / "discord_to_github.json", "w") as f:
json.dump(mappings, f, indent=2)
print(f"✅ Removed {self.github_username} from discord_to_github.json")
else:
print("⚠️ No GitHub username found in discord_to_github.json")
async def remove_from_github_teams(self) -> None:
"""Print instructions to remove member from GitHub organization teams."""
if not self.github_username:
print("⚠️ Skipping GitHub removal (no username)")
return
print(f"️ Please manually remove {self.github_username} from GitHub teams:")
print(f" - Team: {self.team_name}")
if self.role == "leader":
print(f" - Team: {self.team_name} Leaders")
async def remove_discord_roles(self) -> None:
"""Remove Discord roles from the member."""
with open(DATA_DIR / "team_message_ids.json") as f:
team_message_data = json.load(f)
if self.team_name not in team_message_data:
print(f"⚠️ Could not find role ID for team {self.team_name}")
return
self.team_role_id = team_message_data[self.team_name]["role_id"]
url = (
f"{DISCORD_API_BASE}/guilds/{DISCORD_GUILD_ID}"
f"/members/{self.discord_id}/roles/{self.team_role_id}"
)
async with self.session.delete(url, headers=self.headers) as response:
if response.status == 204:
print(f"✅ Removed {self.team_name} Discord role")
else:
print(f"❌ Failed to remove team role: {response.status}")
url = (
f"{DISCORD_API_BASE}/guilds/{DISCORD_GUILD_ID}"
f"/members/{self.discord_id}/roles/{COHORT_ROLE_ID}"
)
async with self.session.delete(url, headers=self.headers) as response:
if response.status == 204:
print("✅ Removed Spring 2026 Cohort Discord role")
else:
print(f"❌ Failed to remove cohort role: {response.status}")
async def send_team_message(self) -> None:
"""Send a message to the team channel announcing the removal."""
channel_id = TEXT_CHANNEL_IDS[self.team_name]
message = (
f"<@{self.discord_id}> has been removed from the cohort for inactivity."
)
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages"
data = {"content": message}
async with self.session.post(url, headers=self.headers, json=data) as response:
if response.status == 200:
print(f"✅ Sent removal message to {self.team_name} channel")
else:
print(f"❌ Failed to send message: {response.status}")
def generate_cohort_notes(self) -> str:
"""Generate text to add to COHORT_NOTES.md."""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
notes = f"\n## {today} - Member Removal\n\n"
notes += "| Discord ID | GitHub Username | Team | Role |\n"
notes += "|------------|-----------------|------|------|\n"
notes += (
f"| {self.discord_id} | {self.github_username or '-'} "
f"| {self.team_name} | {self.role.capitalize()} |\n"
)
return notes
async def main() -> None:
"""Remove a member from the cohort."""
if len(sys.argv) != 2:
print("Usage: python remove_member.py <discord_id>")
sys.exit(1)
discord_id = sys.argv[1]
async with MemberRemover(discord_id) as remover:
if not remover.find_member_info():
print(f"❌ Discord ID {discord_id} not found in any team")
sys.exit(1)
print("\n📋 Found member:")
print(f" Discord ID: {discord_id}")
print(f" Team: {remover.team_name}")
print(f" Role: {remover.role}")
print()
confirm = input("Proceed with removal? (yes/no): ")
if confirm.lower() != "yes":
print("❌ Removal cancelled")
sys.exit(0)
print("\n🚀 Starting removal process...\n")
remover.remove_from_team_assignments()
remover.remove_from_discord_to_github()
await remover.remove_from_github_teams()
await remover.remove_discord_roles()
await remover.send_team_message()
print("\n" + "=" * 80)
print("📝 Add this to COHORT_NOTES.md:")
print("=" * 80)
print(remover.generate_cohort_notes())
print("=" * 80)
print("\n✅ Member removal complete!")
if __name__ == "__main__":
asyncio.run(main())