generated from nhcarrigan/template
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
## 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>
248 lines
8.5 KiB
Python
248 lines
8.5 KiB
Python
#!/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())
|