generated from nhcarrigan/template
feat: reorganise bash scripts and add comprehensive documentation (#6)
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
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>
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user