From 4fdb5d06f1426e81af961ab700a14e6d3c444ae5 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 23 Feb 2026 15:23:10 -0800 Subject: [PATCH] feat: port remaining cohort scripts and make reusable - Port 19 cohort scripts from /home/naomi/docs/cohort/ - Replace all hardcoded tokens and dotenv usage with os.environ - Add pandas==3.0.1 dependency - Add E501 to ruff ignore list for Discord message string content - Make remove_resigned_members.py reusable (empty RESIGNED_IDS constant) - Make update_roster_messages.py reusable (iterates all teams from JSON) - Exclude 12 one-off/event-specific scripts as non-reusable --- python/cohort/catch_up_report.py | 516 +++++++++++++++++++++ python/cohort/check_channel_permissions.py | 142 ++++++ python/cohort/check_lengths.py | 75 +++ python/cohort/check_member_status.py | 49 ++ python/cohort/fetch_roster.py | 33 ++ python/cohort/fix_channel_permissions.py | 121 +++++ python/cohort/get_cohort_members.py | 85 ++++ python/cohort/list_all_guild_roles.py | 61 +++ python/cohort/list_discord_roles.py | 48 ++ python/cohort/remove_discord_roles.py | 106 +++++ python/cohort/remove_github_members.sh | 52 +++ python/cohort/remove_member.py | 244 ++++++++++ python/cohort/remove_resigned_members.py | 60 +++ python/cohort/send_activity_report.py | 166 +++++++ python/cohort/send_checkin.py | 83 ++++ python/cohort/send_team_checkin.py | 90 ++++ python/cohort/update_github_teams.sh | 73 +++ python/cohort/update_roster_messages.py | 100 ++++ python/pyproject.toml | 2 + python/requirements.txt | 3 +- 20 files changed, 2108 insertions(+), 1 deletion(-) create mode 100644 python/cohort/catch_up_report.py create mode 100644 python/cohort/check_channel_permissions.py create mode 100644 python/cohort/check_lengths.py create mode 100644 python/cohort/check_member_status.py create mode 100644 python/cohort/fetch_roster.py create mode 100644 python/cohort/fix_channel_permissions.py create mode 100644 python/cohort/get_cohort_members.py create mode 100644 python/cohort/list_all_guild_roles.py create mode 100644 python/cohort/list_discord_roles.py create mode 100644 python/cohort/remove_discord_roles.py create mode 100755 python/cohort/remove_github_members.sh create mode 100644 python/cohort/remove_member.py create mode 100644 python/cohort/remove_resigned_members.py create mode 100644 python/cohort/send_activity_report.py create mode 100644 python/cohort/send_checkin.py create mode 100644 python/cohort/send_team_checkin.py create mode 100755 python/cohort/update_github_teams.sh create mode 100644 python/cohort/update_roster_messages.py diff --git a/python/cohort/catch_up_report.py b/python/cohort/catch_up_report.py new file mode 100644 index 0000000..2eb948a --- /dev/null +++ b/python/cohort/catch_up_report.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python3 +"""Catch-Up Activity Report. + +Generates a markdown report of Discord and GitHub activity since Feb 15, 2026. +Covers Discord messages in team channels (+ threads) and GitHub activity +(PRs opened, issues opened, issue comments, PR comments, PR reviews, commits). +""" + +import asyncio +import json +import os +import subprocess +from datetime import datetime, timezone + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +DISCORD_API_BASE = "https://discord.com/api/v10" +GITHUB_API_BASE = "https://api.github.com" +GITHUB_ORG = "nhcarrigan-spring-2026-cohort" + +CUTOFF = datetime(2026, 2, 15, 0, 0, 0, tzinfo=timezone.utc) +CUTOFF_ISO = CUTOFF.isoformat().replace("+00:00", "Z") + +OUTPUT_FILE = "catch_up_report.md" + +TEXT_CHANNEL_IDS: dict[str, str] = { + "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", +} + + +def team_repo_slug(team_name: str) -> str: + """Convert a team name to its repository slug.""" + return team_name.lower().replace(" ", "-") + + +def get_github_token() -> str: + """Retrieve the GitHub token via the gh CLI.""" + result = subprocess.run( + ["gh", "auth", "token"], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +class ActivityCollector: + """Collects Discord and GitHub activity for the catch-up report.""" + + def __init__(self, discord_token: str, github_token: str) -> None: + self.discord_headers = { + "Authorization": f"Bot {discord_token}", + "Content-Type": "application/json", + } + self.github_headers = { + "Authorization": f"Bearer {github_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + self.session: aiohttp.ClientSession | None = None + + async def __aenter__(self) -> "ActivityCollector": + 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() + + async def get_discord_username(self, user_id: str) -> str: + """Fetch a Discord user's display name or username.""" + url = f"{DISCORD_API_BASE}/users/{user_id}" + async with self.session.get(url, headers=self.discord_headers) as response: + if response.status == 429: + retry_after = float((await response.json()).get("retry_after", 1)) + await asyncio.sleep(retry_after) + return await self.get_discord_username(user_id) + if response.status != 200: + return "*(unknown)*" + data = await response.json() + return data.get("global_name") or data.get("username") or "*(unknown)*" + + async def _get_discord_thread_ids(self, channel_id: str) -> list[str]: + """Return IDs of all active and archived threads in a channel.""" + thread_ids: list[str] = [] + + url = f"{DISCORD_API_BASE}/channels/{channel_id}/threads/active" + async with self.session.get(url, headers=self.discord_headers) as response: + if response.status == 200: + data = await response.json() + thread_ids.extend(t["id"] for t in data.get("threads", [])) + + for archive_type in ("public", "private"): + url = ( + f"{DISCORD_API_BASE}/channels/{channel_id}" + f"/threads/archived/{archive_type}" + ) + async with self.session.get(url, headers=self.discord_headers) as response: + if response.status == 200: + data = await response.json() + thread_ids.extend(t["id"] for t in data.get("threads", [])) + + return thread_ids + + async def _count_messages_in_channel( + self, channel_id: str, label: str = "" + ) -> dict[str, int]: + """Count messages per Discord user ID since CUTOFF.""" + counts: dict[str, int] = {} + before_id: str | None = None + page = 0 + + while True: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages?limit=100" + if before_id: + url += f"&before={before_id}" + + async with self.session.get(url, headers=self.discord_headers) as response: + if response.status == 429: + retry_after = float((await response.json()).get("retry_after", 1)) + print(f" [Discord] rate limited, waiting {retry_after:.1f}s...") + await asyncio.sleep(retry_after) + continue + if response.status != 200: + print(f" [Discord] channel {channel_id} → HTTP {response.status}") + break + + messages: list[dict] = await response.json() + if not messages: + break + + page += 1 + prefix = f" ({label})" if label else "" + print( + f" [Discord]{prefix} page {page} — {len(messages)} messages fetched", # noqa: E501 + end="\r", + ) + + reached_cutoff = False + for message in messages: + ts = datetime.fromisoformat( + message["timestamp"].replace("Z", "+00:00") + ) + if ts < CUTOFF: + reached_cutoff = True + break + if message["author"].get("bot", False): + continue + author_id = message["author"]["id"] + counts[author_id] = counts.get(author_id, 0) + 1 + + if reached_cutoff or len(messages) < 100: + print() + break + + before_id = messages[-1]["id"] + await asyncio.sleep(0.5) + + return counts + + async def collect_discord_counts( + self, team_name: str, channel_id: str, member_ids: list[str] + ) -> dict[str, int]: + """Return message counts per member for a team's channel and threads.""" + print(" [Discord] Scanning main channel...") + totals: dict[str, int] = await self._count_messages_in_channel( + channel_id, label="main" + ) + + thread_ids = await self._get_discord_thread_ids(channel_id) + total_threads = len(thread_ids) + for i, thread_id in enumerate(thread_ids, start=1): + print(f" [Discord] Scanning thread {i}/{total_threads}...") + thread_counts = await self._count_messages_in_channel( + thread_id, label=f"thread {i}/{total_threads}" + ) + for user_id, count in thread_counts.items(): + totals[user_id] = totals.get(user_id, 0) + count + await asyncio.sleep(0.3) + + if total_threads == 0: + print(" [Discord] No threads found.") + + return {member_id: totals.get(member_id, 0) for member_id in member_ids} + + async def _github_get_all_pages(self, url: str, params: dict) -> list[dict]: + """Fetch all pages from a paginated GitHub REST API endpoint.""" + results: list[dict] = [] + page = 1 + + while True: + paged_params = {**params, "per_page": 100, "page": page} + async with self.session.get( + url, headers=self.github_headers, params=paged_params + ) as response: + if response.status in (404, 422): + break + if response.status == 403: + print(f" [GitHub] rate limited on {url}, waiting 60s...") + await asyncio.sleep(60) + continue + if response.status != 200: + print(f" [GitHub] {url} → HTTP {response.status}") + break + + data: list[dict] = await response.json() + if not data: + break + + results.extend(data) + + if len(data) < 100: + break + page += 1 + await asyncio.sleep(0.2) + + return results + + async def collect_github_counts( + self, team_name: str, github_usernames: list[str] + ) -> dict[str, dict[str, int]]: + """Return activity counts per member for a team's GitHub repository.""" + repo_slug = team_repo_slug(team_name) + repo = f"{GITHUB_ORG}/{repo_slug}" + print(f" [GitHub] repo: {repo}") + + counts: dict[str, dict[str, int]] = { + username: { + "prs_opened": 0, + "issues_opened": 0, + "issue_comments": 0, + "pr_comments": 0, + "pr_reviews": 0, + "commits": 0, + } + for username in github_usernames + if username + } + + def resolve_username(login: str) -> str | None: + lower = login.lower() + for u in github_usernames: + if u and u.lower() == lower: + return u + return None + + print(" [GitHub] Fetching PRs...") + prs = await self._github_get_all_pages( + f"{GITHUB_API_BASE}/repos/{repo}/pulls", + {"state": "all", "sort": "created", "direction": "desc"}, + ) + print(f" [GitHub] {len(prs)} PRs fetched — counting opens since cutoff...") + for pr in prs: + created_at = datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00")) + if created_at < CUTOFF: + break + login = pr["user"]["login"] + username = resolve_username(login) + if username: + counts[username]["prs_opened"] += 1 + + print(" [GitHub] Fetching issues...") + issues = await self._github_get_all_pages( + f"{GITHUB_API_BASE}/repos/{repo}/issues", + { + "state": "all", + "sort": "created", + "direction": "desc", + "since": CUTOFF_ISO, + }, + ) + print(f" [GitHub] {len(issues)} issues/PRs fetched — counting issue opens...") + for issue in issues: + if "pull_request" in issue: + continue + created_at = datetime.fromisoformat( + issue["created_at"].replace("Z", "+00:00") + ) + if created_at < CUTOFF: + continue + login = issue["user"]["login"] + username = resolve_username(login) + if username: + counts[username]["issues_opened"] += 1 + + print(" [GitHub] Fetching issue comments...") + issue_comments = await self._github_get_all_pages( + f"{GITHUB_API_BASE}/repos/{repo}/issues/comments", + {"sort": "created", "direction": "desc", "since": CUTOFF_ISO}, + ) + print(f" [GitHub] {len(issue_comments)} issue comments fetched.") + for comment in issue_comments: + created_at = datetime.fromisoformat( + comment["created_at"].replace("Z", "+00:00") + ) + if created_at < CUTOFF: + continue + login = comment["user"]["login"] + username = resolve_username(login) + if username: + counts[username]["issue_comments"] += 1 + + print(" [GitHub] Fetching PR review comments...") + pr_comments = await self._github_get_all_pages( + f"{GITHUB_API_BASE}/repos/{repo}/pulls/comments", + {"sort": "created", "direction": "desc", "since": CUTOFF_ISO}, + ) + print(f" [GitHub] {len(pr_comments)} PR review comments fetched.") + for comment in pr_comments: + created_at = datetime.fromisoformat( + comment["created_at"].replace("Z", "+00:00") + ) + if created_at < CUTOFF: + continue + login = comment["user"]["login"] + username = resolve_username(login) + if username: + counts[username]["pr_comments"] += 1 + + all_pr_numbers = [pr["number"] for pr in prs] + total_prs = len(all_pr_numbers) + print(f" [GitHub] Fetching reviews for {total_prs} PRs...") + for i, pr_number in enumerate(all_pr_numbers, start=1): + print(f" [GitHub] PR reviews: {i}/{total_prs}", end="\r") + reviews = await self._github_get_all_pages( + f"{GITHUB_API_BASE}/repos/{repo}/pulls/{pr_number}/reviews", + {}, + ) + for review in reviews: + submitted_at_raw = review.get("submitted_at") + if not submitted_at_raw: + continue + submitted_at = datetime.fromisoformat( + submitted_at_raw.replace("Z", "+00:00") + ) + if submitted_at < CUTOFF: + continue + login = review["user"]["login"] + username = resolve_username(login) + if username: + counts[username]["pr_reviews"] += 1 + await asyncio.sleep(0.1) + if total_prs > 0: + print() + + member_list = list(counts.keys()) + total_members = len(member_list) + print(f" [GitHub] Fetching commits for {total_members} members...") + for i, username in enumerate(member_list, start=1): + print(f" [GitHub] Commits: {i}/{total_members} ({username})", end="\r") + commits = await self._github_get_all_pages( + f"{GITHUB_API_BASE}/repos/{repo}/commits", + {"author": username, "since": CUTOFF_ISO}, + ) + counts[username]["commits"] = len(commits) + await asyncio.sleep(0.2) + if total_members > 0: + print() + + return counts + + +def build_report( + team_data: list[dict], + discord_to_github: dict[str, str], + discord_usernames: dict[str, str], + discord_results: dict[str, dict[str, int]], + github_results: dict[str, dict[str, dict[str, int]]], +) -> str: + """Build the markdown activity report.""" + lines = [ + "# Catch-Up Activity Report", + "", + f"**Period:** 2026-02-15 00:00 UTC → " + f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC", + "", + "## Activity by Team", + "", + "| Discord ID | Discord Username | GitHub Username | Team | " + "Discord Messages | PRs Opened | Issues Opened | Issue Comments | " + "PR Comments | PR Reviews | Commits |", + "|------------|-----------------|-----------------|------|" + "-----------------|------------|---------------|----------------|" + "-------------|------------|---------|", + ] + + for team in team_data: + team_name = team["name"] + if team_name == "Jade Jasmine": + continue + + member_ids = team["leaders"] + team["participants"] + team_discord_counts = discord_results.get(team_name, {}) + team_github_counts = github_results.get(team_name, {}) + + for member_id in member_ids: + github_username = discord_to_github.get(member_id, "") + discord_username = discord_usernames.get(member_id, "*(unknown)*") + discord_msg_count = team_discord_counts.get(member_id, 0) + + if github_username: + gh = team_github_counts.get(github_username, {}) + prs = gh.get("prs_opened", 0) + issues = gh.get("issues_opened", 0) + issue_comments = gh.get("issue_comments", 0) + pr_comments = gh.get("pr_comments", 0) + pr_reviews = gh.get("pr_reviews", 0) + commits = gh.get("commits", 0) + else: + prs = issues = issue_comments = pr_comments = pr_reviews = commits = ( + "N/A" + ) + + lines.append( + f"| {member_id} | {discord_username} | {github_username or 'N/A'} " + f"| {team_name} | {discord_msg_count} | {prs} | {issues} " + f"| {issue_comments} | {pr_comments} | {pr_reviews} | {commits} |" + ) + + lines.append("") + lines.append( + f"*Generated at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC*" + ) + + return "\n".join(lines) + + +async def main() -> None: + """Run the catch-up activity report.""" + print("Loading data files...") + with open("team_assignments.json") as f: + team_data: list[dict] = json.load(f) + + with open("discord_to_github.json") as f: + discord_to_github: dict[str, str] = json.load(f) + + print("Getting GitHub token via gh CLI...") + github_token = get_github_token() + + print(f"\nCollecting activity since {CUTOFF.isoformat()}...\n") + + discord_results: dict[str, dict[str, int]] = {} + github_results: dict[str, dict[str, dict[str, int]]] = {} + discord_usernames: dict[str, str] = {} + + async with ActivityCollector(DISCORD_BOT_TOKEN, github_token) as collector: + all_member_ids: list[str] = [] + for team in team_data: + if team["name"] == "Jade Jasmine": + continue + all_member_ids.extend(team["leaders"] + team["participants"]) + + unique_member_ids = list(dict.fromkeys(all_member_ids)) + total_members = len(unique_member_ids) + print(f"Fetching Discord usernames for {total_members} members...") + for i, member_id in enumerate(unique_member_ids, start=1): + if member_id not in discord_usernames: + print(f" username {i}/{total_members}...", end="\r") + discord_usernames[member_id] = await collector.get_discord_username( + member_id + ) + await asyncio.sleep(0.3) + print(f" Done — {total_members} usernames fetched. ") + + for team in team_data: + team_name = team["name"] + if team_name == "Jade Jasmine": + continue + + print(f"\n=== {team_name} ===") + channel_id = TEXT_CHANNEL_IDS[team_name] + member_ids = team["leaders"] + team["participants"] + + discord_results[team_name] = await collector.collect_discord_counts( + team_name, channel_id, member_ids + ) + + github_usernames_for_team = [ + discord_to_github[mid] + for mid in member_ids + if mid in discord_to_github and discord_to_github[mid] + ] + + github_results[team_name] = await collector.collect_github_counts( + team_name, github_usernames_for_team + ) + + print("\nBuilding report...") + report = build_report( + team_data, + discord_to_github, + discord_usernames, + discord_results, + github_results, + ) + + with open(OUTPUT_FILE, "w") as f: + f.write(report) + + print(f"\n✅ Report saved to {OUTPUT_FILE}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/check_channel_permissions.py b/python/cohort/check_channel_permissions.py new file mode 100644 index 0000000..ed748f5 --- /dev/null +++ b/python/cohort/check_channel_permissions.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Check cohort-team-* channels for incorrect @everyone or @cohort permissions. + +Find channels where @everyone or @cohort has Send Messages or +Send Messages in Threads enabled. +""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +BASE_URL = "https://discord.com/api/v10" +GUILD_ID = "739845668582981683" + +SEND_MESSAGES = 0x0000000000000800 +SEND_MESSAGES_IN_THREADS = 0x0000004000000000 + + +async def check_permissions() -> None: + """Check all cohort-team-* channels for permission issues.""" + headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"} + + async with aiohttp.ClientSession() as session: + print("Fetching channels...") + async with session.get( + f"{BASE_URL}/guilds/{GUILD_ID}/channels", headers=headers + ) as resp: + if resp.status != 200: + error = await resp.text() + print(f"Error fetching channels: {resp.status} - {error}") + return + channels = await resp.json() + + print("Fetching roles...") + async with session.get( + f"{BASE_URL}/guilds/{GUILD_ID}/roles", headers=headers + ) as resp: + if resp.status != 200: + error = await resp.text() + print(f"Error fetching roles: {resp.status} - {error}") + return + roles = await resp.json() + + everyone_role_id = GUILD_ID + + cohort_role_id = None + for role in roles: + if "cohort" in role["name"].lower(): + cohort_role_id = role["id"] + print(f"Found cohort role: {role['name']} ({role['id']})") + break + + if not cohort_role_id: + print("Warning: Could not find @cohort role!") + + cohort_channels = [ + ch + for ch in channels + if ch["name"].startswith("cohort-team-") and ch["type"] == 0 + ] + + print(f"\nFound {len(cohort_channels)} cohort-team-* channels\n") + + problematic_channels = [] + + for channel in sorted(cohort_channels, key=lambda x: x["name"]): + channel_name = channel["name"] + channel_id = channel["id"] + permission_overwrites = channel.get("permission_overwrites", []) + + everyone_perms = None + cohort_perms = None + + for overwrite in permission_overwrites: + if overwrite["id"] == everyone_role_id: + everyone_perms = overwrite + elif cohort_role_id and overwrite["id"] == cohort_role_id: + cohort_perms = overwrite + + issues = [] + + if everyone_perms: + deny = int(everyone_perms.get("deny", "0")) + allow = int(everyone_perms.get("allow", "0")) + + if (allow & SEND_MESSAGES) or not (deny & SEND_MESSAGES): + issues.append("@everyone can send messages") + + if (allow & SEND_MESSAGES_IN_THREADS) or not ( + deny & SEND_MESSAGES_IN_THREADS + ): + issues.append("@everyone can send messages in threads") + else: + issues.append( + "@everyone has no permission overwrite (inheriting server perms)" + ) + + if cohort_perms and cohort_role_id: + deny = int(cohort_perms.get("deny", "0")) + allow = int(cohort_perms.get("allow", "0")) + + if (allow & SEND_MESSAGES) or not (deny & SEND_MESSAGES): + issues.append("@cohort can send messages") + + if (allow & SEND_MESSAGES_IN_THREADS) or not ( + deny & SEND_MESSAGES_IN_THREADS + ): + issues.append("@cohort can send messages in threads") + elif cohort_role_id: + issues.append( + "@cohort has no permission overwrite (inheriting server perms)" + ) + + if issues: + problematic_channels.append( + {"name": channel_name, "id": channel_id, "issues": issues} + ) + print(f"❌ {channel_name}") + for issue in issues: + print(f" - {issue}") + else: + print(f"✅ {channel_name}") + + print("\n" + "=" * 60) + print( + f"\nSummary: {len(problematic_channels)} channels with permission issues\n" + ) + + if problematic_channels: + print("Problematic channels:") + for ch in problematic_channels: + print(f"\n{ch['name']} (ID: {ch['id']})") + for issue in ch["issues"]: + print(f" • {issue}") + else: + print("All channels have correct permissions! 🎉") + + +if __name__ == "__main__": + asyncio.run(check_permissions()) diff --git a/python/cohort/check_lengths.py b/python/cohort/check_lengths.py new file mode 100644 index 0000000..a4a39f6 --- /dev/null +++ b/python/cohort/check_lengths.py @@ -0,0 +1,75 @@ +"""Quick dry-run to check Discord message lengths before sending.""" + +FIELDS = [ + ("Discord Username", "Name", 18), + ("Discord Messages", "Msgs", 5), + ("PRs Opened", "PRs", 4), + ("Issues Opened", "Issues", 6), + ("Issue Comments", "Issue♟", 7), + ("PR Comments", "PR♟", 5), + ("PR Reviews", "Reviews", 7), + ("Commits", "Commits", 7), +] + +REPORT_PATH = "catch_up_report.md" + + +def parse_report(path: str) -> dict[str, list[dict]]: + """Parse the markdown table from catch_up_report.md into team → rows.""" + teams: dict[str, list[dict]] = {} + with open(path, encoding="utf-8") as f: + lines = f.readlines() + header_line = None + for i, line in enumerate(lines): + if line.startswith("| Discord ID |"): + header_line = i + break + headers = [h.strip() for h in lines[header_line].strip().strip("|").split("|")] + for line in lines[header_line + 2 :]: + line = line.strip() + if not line.startswith("|"): + break + vals = [v.strip() for v in line.strip().strip("|").split("|")] + row = dict(zip(headers, vals)) + teams.setdefault(row["Team"], []).append(row) + return teams + + +def format_table(members: list[dict]) -> str: + """Format a team's member list as a monospace table for Discord.""" + members = sorted(members, key=lambda r: int(r["Discord Messages"]), reverse=True) + col_widths = [w for _, _, w in FIELDS] + col_headers = [h for _, h, _ in FIELDS] + max_name = max(len(m["Discord Username"]) for m in members) + col_widths[0] = max(col_widths[0], max_name) + + def pad(val: str, width: int, right_align: bool = False) -> str: + return val.rjust(width) if right_align else val.ljust(width) + + header_row = " ".join( + pad(col_headers[i], col_widths[i], right_align=(i > 0)) + for i in range(len(FIELDS)) + ) + separator = " ".join("-" * w for w in col_widths) + rows = [] + for m in members: + vals = [m[key] for key, _, _ in FIELDS] + row = " ".join( + pad(vals[i], col_widths[i], right_align=(i > 0)) for i in range(len(FIELDS)) + ) + rows.append(row) + return "\n".join([header_row, separator] + rows) + + +def main() -> None: + """Check Discord message lengths for all teams.""" + teams = parse_report(REPORT_PATH) + for team, members in teams.items(): + table = format_table(members) + msg = f"**{team} — Activity Report (Feb 15–23)**\n```\n{table}\n```" + status = "OK" if len(msg) <= 2000 else f"OVER by {len(msg) - 2000}" + print(f"{team}: {len(msg)} chars — {status}") + + +if __name__ == "__main__": + main() diff --git a/python/cohort/check_member_status.py b/python/cohort/check_member_status.py new file mode 100644 index 0000000..967b28d --- /dev/null +++ b/python/cohort/check_member_status.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Check if removed members are still in the Discord server.""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +BASE_URL = "https://discord.com/api/v10" +GUILD_ID = "1354624415861833870" + +SAMPLE_MEMBERS = [ + "899092786802987069", + "1318882254365397032", + "799293680799711273", + "237793557992308736", +] + + +async def check_member(session: aiohttp.ClientSession, user_id: str) -> bool | None: + """Check if a member is in the server.""" + url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}" + headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"} + + async with session.get(url, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + roles = data.get("roles", []) + print(f"✅ User {user_id} IS in server - has {len(roles)} roles: {roles}") + return True + if resp.status == 404: + print(f"❌ User {user_id} NOT in server (left or was kicked)") + return False + error = await resp.text() + print(f"⚠️ Error checking {user_id}: {resp.status} - {error}") + return None + + +async def main() -> None: + """Check if sample members are still in the server.""" + async with aiohttp.ClientSession() as session: + for user_id in SAMPLE_MEMBERS: + await check_member(session, user_id) + await asyncio.sleep(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/fetch_roster.py b/python/cohort/fetch_roster.py new file mode 100644 index 0000000..19ffcd2 --- /dev/null +++ b/python/cohort/fetch_roster.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Fetch pinned messages from Ivory Orchid channel to find the roster.""" + +import asyncio +import json +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +CHANNEL_ID = "1464316770889240730" + + +async def main() -> None: + """Fetch and print pinned messages from the Ivory Orchid channel.""" + headers = { + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "Content-Type": "application/json", + } + + async with ( + aiohttp.ClientSession() as session, + session.get( + f"https://discord.com/api/v10/channels/{CHANNEL_ID}/pins", + headers=headers, + ) as response, + ): + pins = await response.json() + print(json.dumps(pins, indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/fix_channel_permissions.py b/python/cohort/fix_channel_permissions.py new file mode 100644 index 0000000..f159d7b --- /dev/null +++ b/python/cohort/fix_channel_permissions.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Fix permissions for cohort-team-michael-and-yoon channel. + +Deny Send Messages in Threads for @everyone and @cohort. +""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +BASE_URL = "https://discord.com/api/v10" +GUILD_ID = "739845668582981683" +CHANNEL_ID = "1467964405646885237" + +SEND_MESSAGES = 0x0000000000000800 +SEND_MESSAGES_IN_THREADS = 0x0000004000000000 + +EVERYONE_ROLE_ID = GUILD_ID +COHORT_ROLE_ID = "1390925253102010521" + + +async def fix_permissions() -> None: + """Fix the channel permissions.""" + headers = { + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + print("Fetching current channel permissions...") + async with session.get( + f"{BASE_URL}/channels/{CHANNEL_ID}", headers=headers + ) as resp: + if resp.status != 200: + error = await resp.text() + print(f"Error fetching channel: {resp.status} - {error}") + return + + channel = await resp.json() + print(f"Channel: {channel['name']}") + + permission_overwrites = channel.get("permission_overwrites", []) + + everyone_overwrite = None + cohort_overwrite = None + + for overwrite in permission_overwrites: + if overwrite["id"] == EVERYONE_ROLE_ID: + everyone_overwrite = overwrite + elif overwrite["id"] == COHORT_ROLE_ID: + cohort_overwrite = overwrite + + print("\nFixing @everyone permissions...") + if everyone_overwrite: + current_deny = int(everyone_overwrite.get("deny", "0")) + current_allow = int(everyone_overwrite.get("allow", "0")) + + new_deny = current_deny | SEND_MESSAGES | SEND_MESSAGES_IN_THREADS + new_allow = current_allow & ~SEND_MESSAGES & ~SEND_MESSAGES_IN_THREADS + + payload = { + "type": 0, + "deny": str(new_deny), + "allow": str(new_allow), + } + else: + payload = { + "type": 0, + "deny": str(SEND_MESSAGES | SEND_MESSAGES_IN_THREADS), + "allow": "0", + } + + async with session.put( + f"{BASE_URL}/channels/{CHANNEL_ID}/permissions/{EVERYONE_ROLE_ID}", + headers=headers, + json=payload, + ) as resp: + if resp.status == 204: + print("✅ @everyone permissions fixed!") + else: + error = await resp.text() + print(f"❌ Error fixing @everyone: {resp.status} - {error}") + + print("\nFixing @cohort permissions...") + if cohort_overwrite: + current_deny = int(cohort_overwrite.get("deny", "0")) + current_allow = int(cohort_overwrite.get("allow", "0")) + + new_deny = current_deny | SEND_MESSAGES | SEND_MESSAGES_IN_THREADS + new_allow = current_allow & ~SEND_MESSAGES & ~SEND_MESSAGES_IN_THREADS + + payload = { + "type": 0, + "deny": str(new_deny), + "allow": str(new_allow), + } + else: + payload = { + "type": 0, + "deny": str(SEND_MESSAGES | SEND_MESSAGES_IN_THREADS), + "allow": "0", + } + + async with session.put( + f"{BASE_URL}/channels/{CHANNEL_ID}/permissions/{COHORT_ROLE_ID}", + headers=headers, + json=payload, + ) as resp: + if resp.status == 204: + print("✅ @cohort permissions fixed!") + else: + error = await resp.text() + print(f"❌ Error fixing @cohort: {resp.status} - {error}") + + print("\n✨ Done! Permissions have been fixed.") + + +if __name__ == "__main__": + asyncio.run(fix_permissions()) diff --git a/python/cohort/get_cohort_members.py b/python/cohort/get_cohort_members.py new file mode 100644 index 0000000..4ad2940 --- /dev/null +++ b/python/cohort/get_cohort_members.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Get all members who currently have the Cohort role.""" + +import asyncio +import json +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" +COHORT_ROLE_ID = "1464314780935258112" + + +async def get_all_members_with_role( + session: aiohttp.ClientSession, +) -> list[dict[str, str]]: + """Get all members who have the Cohort role.""" + members = [] + headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"} + + after = None + + while True: + url = f"https://discord.com/api/v10/guilds/{GUILD_ID}/members" + params: dict[str, str | int] = {"limit": 1000} + if after: + params["after"] = after + + async with session.get(url, headers=headers, params=params) as resp: + if resp.status != 200: + error_text = await resp.text() + raise RuntimeError( + f"Failed to fetch members: {resp.status} - {error_text}" + ) + + data = await resp.json() + + if not data: + break + + for member in data: + if COHORT_ROLE_ID in member.get("roles", []): + members.append( + { + "id": member["user"]["id"], + "username": member["user"]["username"], + "display_name": member.get("nick") + or member["user"]["username"], + } + ) + + if len(data) < 1000: + break + + after = data[-1]["user"]["id"] + + return members + + +async def main() -> None: + """Get all cohort members with the Cohort role.""" + async with aiohttp.ClientSession() as session: + print("Fetching all members with Cohort role...") + + cohort_members = await get_all_members_with_role(session) + + print(f"\n✨ Found {len(cohort_members)} members with the Cohort role:\n") + + for i, member in enumerate(cohort_members, 1): + print( + f"{i}. {member['display_name']} (@{member['username']}) - ID: {member['id']}" # noqa: E501 + ) + + with open("active_cohort_members.json", "w") as f: + json.dump(cohort_members, f, indent=2) + + print("\nSaved to active_cohort_members.json") + + print("\nMember IDs only:") + print(json.dumps([m["id"] for m in cohort_members], indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/list_all_guild_roles.py b/python/cohort/list_all_guild_roles.py new file mode 100644 index 0000000..9c67647 --- /dev/null +++ b/python/cohort/list_all_guild_roles.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""List all roles in the guild to find the correct team role IDs.""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +BASE_URL = "https://discord.com/api/v10" +GUILD_ID = "1354624415861833870" + + +async def list_guild_roles() -> None: + """List all roles in the guild, highlighting team-related ones.""" + url = f"{BASE_URL}/guilds/{GUILD_ID}/roles" + headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"} + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=headers) as resp, + ): + if resp.status == 200: + roles = await resp.json() + print(f"Found {len(roles)} roles in the server:\n") + + team_names = [ + "Jade Jasmine", + "Crimson Dahlia", + "Rose Camellia", + "Amber Wisteria", + "Ivory Orchid", + "Teal Iris", + "Peach Gardenia", + "Violet Carnation", + "Azure Lotus", + "Coral Sunflower", + "Indigo Tulip", + "Scarlet Hydrangea", + "Mint Narcissus", + "Sage Marigold", + "Cohort", + ] + + for role in sorted(roles, key=lambda r: r["name"].lower()): + name = role["name"] + is_team = any(team in name for team in team_names) + if is_team or "spring" in name.lower() or "2026" in name: + print(f"✅ {role['id']}: {name}") + + print("\n\nAll roles (sorted by name):") + for role in sorted(roles, key=lambda r: r["name"].lower()): + print(f"{role['id']}: {role['name']}") + + else: + error = await resp.text() + print(f"Error: {resp.status} - {error}") + + +if __name__ == "__main__": + asyncio.run(list_guild_roles()) diff --git a/python/cohort/list_discord_roles.py b/python/cohort/list_discord_roles.py new file mode 100644 index 0000000..b5ade22 --- /dev/null +++ b/python/cohort/list_discord_roles.py @@ -0,0 +1,48 @@ +"""List all Discord roles in the freeCodeCamp server.""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" + +API_BASE = "https://discord.com/api/v10" + + +async def get_guild_roles(session: aiohttp.ClientSession) -> list[dict]: + """Get all guild roles.""" + url = f"{API_BASE}/guilds/{GUILD_ID}/roles" + headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"} + + async with session.get(url, headers=headers) as resp: + if resp.status == 200: + return await resp.json() + print(f"Failed to get roles: {resp.status}") + text = await resp.text() + print(text) + return [] + + +async def main() -> None: + """List all roles from freeCodeCamp Discord, highlighting cohort-related ones.""" + async with aiohttp.ClientSession() as session: + print("Fetching all roles from freeCodeCamp Discord...\n") + roles = await get_guild_roles(session) + + print(f"Found {len(roles)} roles:\n") + + cohort_roles = [ + r + for r in roles + if "2026" in r["name"] or "Cohort" in r["name"] or "Leader" in r["name"] + ] + + print("=== Cohort/Leader Roles ===") + for role in cohort_roles: + print(f" {role['name']}: {role['id']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/remove_discord_roles.py b/python/cohort/remove_discord_roles.py new file mode 100644 index 0000000..b0149a6 --- /dev/null +++ b/python/cohort/remove_discord_roles.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Remove cohort and team roles from inactive Discord members.""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "1354624415861833870" +BASE_URL = "https://discord.com/api/v10" + +INACTIVE_MEMBERS = [ + "1177481351490981889", + "899092786802987069", + "1318882254365397032", + "680429718511943770", + "1195902319976521748", + "1424001797072359576", + "1333378962289590365", + "799293680799711273", + "1183395404293869662", + "1325958873831440566", + "717028253633871965", + "847789364217905182", + "746703502369095700", + "192541018908786690", + "1017761694514163712", +] + +COHORT_ROLE_ID = "1464314780935258112" +TEAM_ROLE_IDS = { + 1: "1464314923780931677", + 2: "1464315093402784015", + 3: "1464315098452726106", + 4: "1464315105264275600", + 5: "1464315109873684593", + 6: "1464315114378498152", + 7: "1464315118904152107", + 8: "1464315124251754559", + 9: "1464315128437801177", + 10: "1464315132896088168", + 11: "1464315138428633241", + 12: "1464315142710890520", + 13: "1464315149203804405", + 14: "1464315153599299803", +} + +MEMBER_TO_TEAM = { + "1177481351490981889": 1, + "899092786802987069": 1, + "1318882254365397032": 2, + "680429718511943770": 2, + "1195902319976521748": 3, + "1424001797072359576": 4, + "1333378962289590365": 4, + "799293680799711273": 5, + "1183395404293869662": 7, + "1325958873831440566": 8, + "717028253633871965": 8, + "847789364217905182": 10, + "746703502369095700": 10, + "192541018908786690": 11, + "1017761694514163712": 13, +} + + +async def remove_role( + session: aiohttp.ClientSession, user_id: str, role_id: str +) -> bool: + """Remove a role from a user.""" + url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}" + headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"} + + async with session.delete(url, headers=headers) as resp: + if resp.status == 204: + return True + text = await resp.text() + print(f" Error removing role {role_id} from {user_id}: {resp.status} - {text}") + return False + + +async def main() -> None: + """Remove roles from all inactive members.""" + async with aiohttp.ClientSession() as session: + print("Removing roles from inactive members...\n") + + for member_id in INACTIVE_MEMBERS: + print(f"Processing {member_id}...") + + if await remove_role(session, member_id, COHORT_ROLE_ID): + print(" ✓ Removed cohort role") + + if member_id in MEMBER_TO_TEAM: + team_id = MEMBER_TO_TEAM[member_id] + team_role_id = TEAM_ROLE_IDS[team_id] + if await remove_role(session, member_id, team_role_id): + print(f" ✓ Removed Team {team_id} role") + + await asyncio.sleep(1.5) + + print("\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/remove_github_members.sh b/python/cohort/remove_github_members.sh new file mode 100755 index 0000000..8636c2c --- /dev/null +++ b/python/cohort/remove_github_members.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Script to remove inactive members from GitHub organization teams +# Date: 2026-02-12 + +ORG="nhcarrigan-spring-2026-cohort" + +# Team 1 (Jade Jasmine) - Remove leader and participant +echo "Removing from Jade Jasmine..." +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine-leaders/memberships/Mista-Log" || true +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/A-normal-programmer" || true + +# Team 2 (Crimson Dahlia) - Remove 2 participants +echo "Removing from Crimson Dahlia..." +gh api --method DELETE "/orgs/$ORG/teams/crimson-dahlia/memberships/1s-crypto" || true +gh api --method DELETE "/orgs/$ORG/teams/crimson-dahlia/memberships/emlanis" || true + +# Team 3 (Rose Camellia) - Remove leader +echo "Removing from Rose Camellia..." +gh api --method DELETE "/orgs/$ORG/teams/rose-camellia-leaders/memberships/michaelboateng1" || true + +# Team 4 (Amber Wisteria) - Remove leader and participant +echo "Removing from Amber Wisteria..." +gh api --method DELETE "/orgs/$ORG/teams/amber-wisteria-leaders/memberships/neonbit101" || true +gh api --method DELETE "/orgs/$ORG/teams/amber-wisteria/memberships/avanishchandra" || true + +# Team 5 (Ivory Orchid) - Remove participant +echo "Removing from Ivory Orchid..." +gh api --method DELETE "/orgs/$ORG/teams/ivory-orchid/memberships/VuBui217" || true + +# Team 7 (Peach Gardenia) - Remove participant +echo "Removing from Peach Gardenia..." +gh api --method DELETE "/orgs/$ORG/teams/peach-gardenia/memberships/TabsOO7" || true + +# Team 8 (Violet Carnation) - Remove 2 participants +echo "Removing from Violet Carnation..." +gh api --method DELETE "/orgs/$ORG/teams/violet-carnation/memberships/masudulalam" || true +gh api --method DELETE "/orgs/$ORG/teams/violet-carnation/memberships/urmilbhatt" || true + +# Team 10 (Coral Sunflower) - Remove leader and participant +echo "Removing from Coral Sunflower..." +gh api --method DELETE "/orgs/$ORG/teams/coral-sunflower-leaders/memberships/AjayTheWizard" || true +gh api --method DELETE "/orgs/$ORG/teams/coral-sunflower/memberships/Hritikhh" || true + +# Team 11 (Indigo Tulip) - Remove participant +echo "Removing from Indigo Tulip..." +gh api --method DELETE "/orgs/$ORG/teams/indigo-tulip/memberships/SiAust" || true + +# Team 13 (Mint Narcissus) - Remove participant +echo "Removing from Mint Narcissus..." +gh api --method DELETE "/orgs/$ORG/teams/mint-narcissus/memberships/SergioPardoSanchez" || true + +echo "Done removing members from GitHub teams!" diff --git a/python/cohort/remove_member.py b/python/cohort/remove_member.py new file mode 100644 index 0000000..acccb45 --- /dev/null +++ b/python/cohort/remove_member.py @@ -0,0 +1,244 @@ +#!/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 +""" + +import asyncio +import json +import os +import sys +from datetime import datetime, timezone + +import aiohttp + +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("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("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("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("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("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("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 ") + 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()) diff --git a/python/cohort/remove_resigned_members.py b/python/cohort/remove_resigned_members.py new file mode 100644 index 0000000..8c7a029 --- /dev/null +++ b/python/cohort/remove_resigned_members.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Remove resigned members from team_assignments.json. + +Update RESIGNED_IDS with the Discord IDs of members who have resigned +before running this script. +""" + +import json + +# Discord IDs of members who have resigned - update before running +RESIGNED_IDS: list[str] = [] + + +def main() -> None: + """Remove resigned members from team assignments.""" + with open("team_assignments.json") as f: + teams = json.load(f) + + changes_made = [] + + for team in teams: + team_name = team["name"] + + original_leaders = len(team["leaders"]) + team["leaders"] = [lid for lid in team["leaders"] if lid not in RESIGNED_IDS] + if len(team["leaders"]) < original_leaders: + removed = original_leaders - len(team["leaders"]) + changes_made.append(f"Removed {removed} leader(s) from {team_name}") + + original_participants = len(team["participants"]) + team["participants"] = [ + pid for pid in team["participants"] if pid not in RESIGNED_IDS + ] + if len(team["participants"]) < original_participants: + removed = original_participants - len(team["participants"]) + changes_made.append(f"Removed {removed} participant(s) from {team_name}") + print(f"⚠️ {team_name}: Proficiency distribution may need updating") + + with open("team_assignments.json", "w") as f: + json.dump(teams, f, indent=2) + + if changes_made: + print("✅ Successfully removed resigned members from team_assignments.json") + print("\nChanges made:") + for change in changes_made: + print(f" - {change}") + else: + print("No changes needed - resigned members were not found in team assignments") + + print("\nUpdated team sizes:") + for team in teams: + total_members = len(team["leaders"]) + len(team["participants"]) + print( + f" {team['name']}: {total_members} members " + f"({len(team['leaders'])} leaders, {len(team['participants'])} participants)" # noqa: E501 + ) + + +if __name__ == "__main__": + main() diff --git a/python/cohort/send_activity_report.py b/python/cohort/send_activity_report.py new file mode 100644 index 0000000..01b2914 --- /dev/null +++ b/python/cohort/send_activity_report.py @@ -0,0 +1,166 @@ +"""Send formatted activity report tables to each team channel via Amari bot.""" + +import asyncio +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" +API_BASE = "https://discord.com/api/v10" + +CHANNEL_IDS = { + "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", +} + +REPORT_PATH = os.path.join(os.path.dirname(__file__), "catch_up_report.md") + +FIELDS = [ + ("Discord Username", "Name", 18), + ("Discord Messages", "Msgs", 5), + ("PRs Opened", "PRs", 4), + ("Issues Opened", "Issues", 6), + ("Issue Comments", "Issue♟", 7), + ("PR Comments", "PR♟", 5), + ("PR Reviews", "Reviews", 7), + ("Commits", "Commits", 7), +] + + +def parse_report(path: str) -> dict[str, list[dict]]: + """Parse the markdown table from catch_up_report.md into team → rows.""" + teams: dict[str, list[dict]] = {} + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + header_line = None + for i, line in enumerate(lines): + if line.startswith("| Discord ID |"): + header_line = i + break + + if header_line is None: + raise ValueError("Could not find table header in report") + + headers = [h.strip() for h in lines[header_line].strip().strip("|").split("|")] + for line in lines[header_line + 2 :]: + line = line.strip() + if not line.startswith("|"): + break + row_values = [v.strip() for v in line.strip().strip("|").split("|")] + row = dict(zip(headers, row_values)) + team = row["Team"] + teams.setdefault(team, []).append(row) + + return teams + + +def format_table(members: list[dict]) -> str: + """Format a team's member list as a monospace table for Discord.""" + members = sorted(members, key=lambda r: int(r["Discord Messages"]), reverse=True) + + col_widths = [width for _, _, width in FIELDS] + col_headers = [header for _, header, _ in FIELDS] + + name_col_index = 0 + max_name = max(len(m["Discord Username"]) for m in members) + col_widths[name_col_index] = max(col_widths[name_col_index], max_name) + + def pad(val: str, width: int, right_align: bool = False) -> str: + return val.rjust(width) if right_align else val.ljust(width) + + header_row = " ".join( + pad(col_headers[i], col_widths[i], right_align=(i > 0)) + for i in range(len(FIELDS)) + ) + separator = " ".join("-" * w for w in col_widths) + + rows = [] + for m in members: + source_keys = [key for key, _, _ in FIELDS] + values = [m[key] for key in source_keys] + row = " ".join( + pad(values[i], col_widths[i], right_align=(i > 0)) + for i in range(len(FIELDS)) + ) + rows.append(row) + + return "\n".join([header_row, separator] + rows) + + +async def send_message( + session: aiohttp.ClientSession, channel_id: str, content: str +) -> None: + """Send a message to a Discord channel.""" + headers = { + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "Content-Type": "application/json", + } + url = f"{API_BASE}/channels/{channel_id}/messages" + while True: + async with session.post( + url, json={"content": content}, headers=headers + ) as resp: + if resp.status == 429: + data = await resp.json() + retry_after = data.get("retry_after", 5) + print(f" Rate limited — sleeping {retry_after}s...") + await asyncio.sleep(retry_after) + continue + if resp.status not in (200, 201): + text = await resp.text() + print(f" ERROR {resp.status}: {text}") + return + + +async def main() -> None: + """Send activity tables to all team channels.""" + teams = parse_report(REPORT_PATH) + team_names = list(CHANNEL_IDS.keys()) + print(f"Sending activity tables to {len(team_names)} channels...\n") + + async with aiohttp.ClientSession() as session: + for i, team_name in enumerate(team_names, 1): + channel_id = CHANNEL_IDS[team_name] + members = teams.get(team_name, []) + if not members: + print(f" [{i}/{len(team_names)}] {team_name} — no data, skipping") + continue + + table = format_table(members) + message = ( + f"**{team_name} — Activity Report (Feb 15–23)**\n```\n{table}\n```" + ) + + if len(message) > 2000: + print( + f" [{i}/{len(team_names)}] {team_name} — WARNING: " + f"message is {len(message)} chars (over 2000!)" + ) + + print( + f" [{i}/{len(team_names)}] Sending to {team_name}... ", + end="", + flush=True, + ) + await send_message(session, channel_id, message) + print("sent!") + await asyncio.sleep(1) + + print("\nAll done! 🌸") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/send_checkin.py b/python/cohort/send_checkin.py new file mode 100644 index 0000000..26b142c --- /dev/null +++ b/python/cohort/send_checkin.py @@ -0,0 +1,83 @@ +"""Send check-in messages to all team channels.""" + +import asyncio +import json +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +DISCORD_API = "https://discord.com/api/v10" +GUILD_ID = "692816967895220344" + +CHECK_IN_MESSAGE = """@everyone it is time for Naomi to do a check in. I need **each and every one of you** to respond to this message **in the thread** by Monday, or you **will be removed** for inactivity. + +1. What have you achieved over the last two weeks? +2. What is your focus for the next two weeks? +3. How much time can you **reliably** commit to your team over the next two weeks? +4. Are you facing any **blockers** or **challenges** that are preventing you from contributing? +5. Do you need **help** from your team leaders or from Naomi to get unstuck? +""" + + +async def send_checkin_to_teams() -> None: + """Send check-in message to all team channels (except Jade Jasmine).""" + with open("team_message_ids.json") as f: + team_data = json.load(f) + + headers = { + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + for team_name, data in team_data.items(): + if team_name == "jade-jasmine": + print(f"⏭️ Skipping {team_name} (dissolved)") + continue + + channel_id = data["channel_id"] + print(f"📤 Sending check-in to {team_name} (channel {channel_id})...") + + async with session.post( + f"{DISCORD_API}/channels/{channel_id}/messages", + headers=headers, + json={"content": CHECK_IN_MESSAGE}, + ) as resp: + if resp.status == 200: + message_data = await resp.json() + message_id = message_data["id"] + print(f" ✅ Message sent! Message ID: {message_id}") + + thread_name = ( + f"Check-in Responses - {team_name.replace('-', ' ').title()}" + ) + async with session.post( + f"{DISCORD_API}/channels/{channel_id}/messages/{message_id}/threads", + headers=headers, + json={ + "name": thread_name, + "auto_archive_duration": 10080, + }, + ) as thread_resp: + if thread_resp.status == 201: + thread_data = await thread_resp.json() + thread_id = thread_data["id"] + print(f" 🧵 Thread created! Thread ID: {thread_id}") + else: + error_text = await thread_resp.text() + print( + f" ❌ Failed to create thread: " + f"{thread_resp.status} - {error_text}" + ) + else: + error_text = await resp.text() + print(f" ❌ Failed to send message: {resp.status} - {error_text}") + + await asyncio.sleep(1) + + print("\n✅ Check-in messages sent to all teams!") + + +if __name__ == "__main__": + asyncio.run(send_checkin_to_teams()) diff --git a/python/cohort/send_team_checkin.py b/python/cohort/send_team_checkin.py new file mode 100644 index 0000000..dc66071 --- /dev/null +++ b/python/cohort/send_team_checkin.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Send team check-in messages to all 14 teams.""" + +import asyncio +import json +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +NAOMI_ID = "465650873650118659" + + +def build_checkin_message(team_name: str, team: dict, role_id: str) -> str: + """Build the team check-in message.""" + total_members = len(team["leaders"]) + len(team["participants"]) + num_leaders = len(team["leaders"]) + num_participants = len(team["participants"]) + + leader_text = "leader" if num_leaders == 1 else "leaders" + participant_text = "participant" if num_participants == 1 else "participants" + + return f"""## Team Check-In + +Your team currently has **{total_members} members** ({num_leaders} {leader_text} + {num_participants} {participant_text}). + +Given the recent changes to team size, we want to make sure you feel confident moving forward with your project. Please discuss as a team and let us know: + +**Do you feel you can still complete your project with your current team size?** + +If you have concerns about capacity, need additional support, or would like to discuss options (such as combining with another team or adjusting project scope), please ping <@{NAOMI_ID}> and we'll work together to find a solution. + +Your success is the priority here - we want to make sure every team has what they need to build something amazing! 💜 + +-# <@&{role_id}>""" + + +async def send_message( + session: aiohttp.ClientSession, channel_id: str, content: str +) -> dict | None: + """Send a message to a Discord channel.""" + url = f"https://discord.com/api/v10/channels/{channel_id}/messages" + headers = { + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "Content-Type": "application/json", + } + payload = {"content": content} + + async with session.post(url, headers=headers, json=payload) as resp: + if resp.status in [200, 201]: + return await resp.json() + error_text = await resp.text() + print( + f"❌ Failed to send message to channel {channel_id}: {resp.status} - {error_text}" # noqa: E501 + ) + return None + + +async def main() -> None: + """Send check-in messages to all teams.""" + with open("team_message_ids.json") as f: + team_data = json.load(f) + + with open("team_assignments.json") as f: + teams = json.load(f) + + async with aiohttp.ClientSession() as session: + print("📢 Sending team check-in messages...\n") + + for team in teams: + team_name = team["name"] + channel_id = team_data[team_name]["channel_id"] + role_id = team_data[team_name]["role_id"] + + print(f"Processing {team_name}...") + + checkin_msg = build_checkin_message(team_name, team, role_id) + result = await send_message(session, channel_id, checkin_msg) + + if result: + total = len(team["leaders"]) + len(team["participants"]) + print(f" ✅ Sent check-in ({total} members)") + + await asyncio.sleep(2) + + print("\n✨ Done sending all team check-in messages!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/cohort/update_github_teams.sh b/python/cohort/update_github_teams.sh new file mode 100755 index 0000000..c7e47c2 --- /dev/null +++ b/python/cohort/update_github_teams.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Update GitHub teams for Jade Jasmine dissolution + +set -e # Exit on error + +ORG="nhcarrigan-spring-2026-cohort" + +echo "=== Phase 2: GitHub Team Changes ===" +echo "" + +# Step 1: Remove all members from jade-jasmine team +echo "Step 1: Removing members from jade-jasmine team..." +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/Nikil-D-Gr8" || echo " - Nikil-D-Gr8 already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/amandaw800" || echo " - amandaw800 already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/hayden-html" || echo " - hayden-html already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/hbar1st" || echo " - hbar1st already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/rosacabrerac" || echo " - rosacabrerac already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/ArbitraryPie" || echo " - ArbitraryPie already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/builtbykabir" || echo " - builtbykabir already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/Mista-Log" || echo " - Mista-Log already removed or not found" +echo "✅ jade-jasmine team cleared" +echo "" + +# Step 2: Remove leaders from jade-jasmine-leaders team +echo "Step 2: Removing leaders from jade-jasmine-leaders team..." +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine-leaders/memberships/hayden-html" || echo " - hayden-html already removed or not found" +gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine-leaders/memberships/Mista-Log" || echo " - Mista-Log already removed or not found" +echo "✅ jade-jasmine-leaders team cleared" +echo "" + +# Step 3: Add members to new teams +echo "Step 3: Adding members to new teams..." + +echo " - Adding Nikil-D-Gr8 to crimson-dahlia..." +gh api --method PUT "/orgs/$ORG/teams/crimson-dahlia/memberships/Nikil-D-Gr8" -f role=member + +echo " - Adding amandaw800 to violet-carnation..." +gh api --method PUT "/orgs/$ORG/teams/violet-carnation/memberships/amandaw800" -f role=member + +echo " - Adding hayden-html to teal-iris..." +gh api --method PUT "/orgs/$ORG/teams/teal-iris/memberships/hayden-html" -f role=member + +echo " - Adding hbar1st to indigo-tulip..." +gh api --method PUT "/orgs/$ORG/teams/indigo-tulip/memberships/hbar1st" -f role=member + +echo " - Adding rosacabrerac to scarlet-hydrangea..." +gh api --method PUT "/orgs/$ORG/teams/scarlet-hydrangea/memberships/rosacabrerac" -f role=member + +echo " - Adding ArbitraryPie to peach-gardenia..." +gh api --method PUT "/orgs/$ORG/teams/peach-gardenia/memberships/ArbitraryPie" -f role=member + +echo " - Adding builtbykabir to azure-lotus..." +gh api --method PUT "/orgs/$ORG/teams/azure-lotus/memberships/builtbykabir" -f role=member + +echo " - Adding Mista-Log to ivory-orchid..." +gh api --method PUT "/orgs/$ORG/teams/ivory-orchid/memberships/Mista-Log" -f role=member + +echo "✅ All members added to new teams" +echo "" + +# Step 4: Add Mista-Log to ivory-orchid-leaders +echo "Step 4: Adding Mista-Log to ivory-orchid-leaders..." +gh api --method PUT "/orgs/$ORG/teams/ivory-orchid-leaders/memberships/Mista-Log" -f role=member +echo "✅ Mista-Log promoted to leader in Ivory Orchid" +echo "" + +echo "=== Phase 2 Complete! ===" +echo "" +echo "Summary:" +echo "- ✅ jade-jasmine team cleared (8 members removed)" +echo "- ✅ jade-jasmine-leaders team cleared (2 leaders removed)" +echo "- ✅ 8 members added to their new teams" +echo "- ✅ Mista-Log promoted to leader in ivory-orchid" diff --git a/python/cohort/update_roster_messages.py b/python/cohort/update_roster_messages.py new file mode 100644 index 0000000..d181ba9 --- /dev/null +++ b/python/cohort/update_roster_messages.py @@ -0,0 +1,100 @@ +"""Update team roster messages in Discord from team_assignments.json.""" + +import asyncio +import json +import os + +import aiohttp + +DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" + +API_BASE = "https://discord.com/api/v10" + + +async def edit_message( + session: aiohttp.ClientSession, + channel_id: str, + message_id: str, + content: str, +) -> bool: + """Edit a Discord message.""" + url = f"{API_BASE}/channels/{channel_id}/messages/{message_id}" + headers = { + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "Content-Type": "application/json", + } + payload = {"content": content} + + async with session.patch(url, headers=headers, json=payload) as resp: + if resp.status == 200: + return True + text = await resp.text() + print(f" ❌ Failed to edit message: {resp.status} - {text}") + return False + + +def generate_roster(team: dict, discord_to_github: dict) -> str: + """Generate roster message for a team.""" + team_name = team["name"] + + leader_lines = [ + f"- <@{discord_id}> ({discord_to_github.get(discord_id, 'Unknown')})" + for discord_id in team["leaders"] + ] + + participant_lines = [ + f"- <@{discord_id}> ({discord_to_github.get(discord_id, 'Unknown')})" + for discord_id in team["participants"] + ] + + leaders_text = "\n".join(leader_lines) if leader_lines else "None" + participants_text = "\n".join(participant_lines) if participant_lines else "None" + + return f"""# Team {team_name} + +**Leaders:** +{leaders_text} + +**Participants:** +{participants_text}""" + + +async def main() -> None: + """Update roster messages for all teams.""" + with open("team_message_ids.json") as f: + team_data = json.load(f) + + with open("team_assignments.json") as f: + teams = json.load(f) + + with open("discord_to_github.json") as f: + discord_to_github = json.load(f) + + async with aiohttp.ClientSession() as session: + for team in teams: + team_name = team["name"] + print(f"Updating roster for {team_name}...") + + if team_name not in team_data: + print(" ⚠️ Team not found in team_message_ids.json") + continue + + channel_id = team_data[team_name]["channel_id"] + message_id = team_data[team_name]["message_id"] + + roster_content = generate_roster(team, discord_to_github) + + success = await edit_message( + session, channel_id, message_id, roster_content + ) + if success: + print(f" ✅ Updated (Message ID: {message_id})") + + await asyncio.sleep(0.5) + + print("\n✅ All roster messages updated!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/pyproject.toml b/python/pyproject.toml index 9456b09..303e0d2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -79,6 +79,8 @@ ignore = [ "DTZ001", # Ambiguous variable names - context makes it clear "E741", + # Long lines in string literals (Discord messages, URLs) + "E501", ] [tool.ruff.lint.pydocstyle] diff --git a/python/requirements.txt b/python/requirements.txt index a366b7b..eb03cc8 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -3,4 +3,5 @@ ruff==0.14.14 # Runtime dependencies requests==2.32.3 -aiohttp==3.11.12 \ No newline at end of file +aiohttp==3.11.12 +pandas==3.0.1 \ No newline at end of file