From 4fdb5d06f1426e81af961ab700a14e6d3c444ae5 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 23 Feb 2026 15:23:10 -0800 Subject: [PATCH 1/5] 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 -- 2.52.0 From a40188413af4a86aa571c60e3cfc8692d80deb9a Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 23 Feb 2026 15:42:03 -0800 Subject: [PATCH 2/5] docs: add data file documentation and fix data path resolution All Python cohort scripts now use DATA_DIR = Path(__file__).parent.parent.parent / "data" to correctly resolve the repo-root data/ directory regardless of the working directory set by run.sh. All TypeScript scripts have expanded JSDoc headers documenting data file requirements and environment variables. --- python/cohort/add_github_team_members.py | 20 ++++++++-- python/cohort/analyse_availability.py | 26 +++++++++++-- python/cohort/assign_cohort_role.py | 5 ++- python/cohort/assign_team_roles.py | 5 ++- python/cohort/catch_up_report.py | 7 +++- python/cohort/check_lengths.py | 15 +++++++- python/cohort/discord_activity_checker.py | 22 +++++++++-- .../cohort/evaluate_technical_proficiency.py | 23 +++++++++++- python/cohort/fix_channel_permissions.py | 12 +++++- python/cohort/generate_member_files.py | 37 +++++++++++++++---- python/cohort/generate_timeslots.py | 17 ++++++++- python/cohort/get_cohort_members.py | 5 ++- python/cohort/remove_discord_roles.py | 12 +++++- python/cohort/remove_member.py | 15 +++++--- python/cohort/remove_resigned_members.py | 7 +++- python/cohort/send_activity_report.py | 16 +++++++- python/cohort/send_checkin.py | 18 ++++++++- python/cohort/send_team_checkin.py | 20 ++++++++-- python/cohort/send_team_messages.py | 25 +++++++++++-- python/cohort/update_roster_messages.py | 9 +++-- python/cohort/verify_discord.py | 22 ++++++++++- .../src/crowdin/clearHiddenTranslations.ts | 11 ++++++ typescript/src/crowdin/reapplyTranslations.ts | 8 ++++ typescript/src/discord/cycThreads.ts | 6 +++ typescript/src/discord/guildCount.ts | 10 +++++ .../src/discourse/bulkUpdateCategories.ts | 8 ++++ typescript/src/discourse/closeOldTopics.ts | 7 ++++ typescript/src/gitea/deleteFromAllRepos.ts | 6 +++ typescript/src/gitea/uploadToAllRepos.ts | 7 ++++ .../src/gitea/uploadToReposConditionally.ts | 7 ++++ typescript/src/github/auditNpmPackages.ts | 8 ++++ typescript/src/github/onboardMentee.ts | 8 ++++ typescript/src/github/postUserStories.ts | 7 ++++ typescript/src/music/id3v2.ts | 6 +++ typescript/src/s3/bulkUpload.ts | 9 +++++ typescript/src/s3/correctContentType.ts | 8 ++++ typescript/src/s3/deleteContents.ts | 8 ++++ typescript/src/s3/upload.ts | 8 ++++ typescript/src/security/generateReport.ts | 7 ++++ 39 files changed, 424 insertions(+), 53 deletions(-) diff --git a/python/cohort/add_github_team_members.py b/python/cohort/add_github_team_members.py index dafda8a..175385f 100755 --- a/python/cohort/add_github_team_members.py +++ b/python/cohort/add_github_team_members.py @@ -1,15 +1,29 @@ #!/usr/bin/env python3 -"""Add GitHub users to their appropriate teams in nhcarrigan-spring-2026-cohort org""" +"""Add GitHub users to their appropriate teams in the cohort GitHub organisation. + +Uses the GitHub CLI to add each member to their corresponding team and, for +leaders, to the team's leaders sub-team. + +Data files (place in data/): + - team_assignments.json Team rosters with leaders and participants per team + - discord_to_github.json Mapping of Discord IDs to GitHub usernames + +Env vars: + - None (uses `gh` CLI for authentication) +""" import json import subprocess import time +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" # Load team assignments and Discord to GitHub mappings -with open("team_assignments.json") as f: +with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) -with open("discord_to_github.json") as f: +with open(DATA_DIR / "discord_to_github.json") as f: discord_to_github = json.load(f) # Map team names to GitHub team slugs diff --git a/python/cohort/analyse_availability.py b/python/cohort/analyse_availability.py index 438ee78..723aa98 100644 --- a/python/cohort/analyse_availability.py +++ b/python/cohort/analyse_availability.py @@ -1,6 +1,26 @@ +"""Analyse applicant availability from a markdown table and produce UTC block stats. + +Reads a markdown table of availability responses and a Discord verification file, +then produces a JSON summary of coverage across morning/afternoon/evening UTC blocks +for each day of the week. + +Data files (place in data/): + - table.md Markdown table of applicant availability responses + - discord_verification.json Discord ID verification results (from verify_discord.py) + +Outputs (written to data/): + - availability_analysis.json UTC block distribution per applicant + +Env vars: + - None +""" + import json import re from collections import defaultdict +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] @@ -99,7 +119,7 @@ def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict: def parse_table_md() -> list[dict]: """Parse table.md and extract availability data""" - with open("table.md") as f: + with open(DATA_DIR / "table.md") as f: content = f.read() lines = content.strip().split("\n") @@ -131,7 +151,7 @@ def parse_table_md() -> list[dict]: def main(): - with open("discord_verification.json") as f: + with open(DATA_DIR / "discord_verification.json") as f: verification = json.load(f) verified_ids = {v[0] for v in verification["verified"]} @@ -167,7 +187,7 @@ def main(): } ) - with open("availability_analysis.json", "w") as f: + with open(DATA_DIR / "availability_analysis.json", "w") as f: json.dump(availability_results, f, indent=2) block_distribution = defaultdict(int) diff --git a/python/cohort/assign_cohort_role.py b/python/cohort/assign_cohort_role.py index 97e0ba7..0a9e26f 100644 --- a/python/cohort/assign_cohort_role.py +++ b/python/cohort/assign_cohort_role.py @@ -6,9 +6,12 @@ Respects Discord rate limits with proper backoff and retry logic. import json import os import time +from pathlib import Path import requests +DATA_DIR = Path(__file__).parent.parent.parent / "data" + BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] GUILD_ID = "692816967895220344" COHORT_ROLE_ID = "1464314780935258112" @@ -48,7 +51,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> def main(): - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) all_users = [] diff --git a/python/cohort/assign_team_roles.py b/python/cohort/assign_team_roles.py index 91bf134..de162d2 100644 --- a/python/cohort/assign_team_roles.py +++ b/python/cohort/assign_team_roles.py @@ -6,9 +6,12 @@ Respects Discord rate limits with proper backoff and retry logic. import json import os import time +from pathlib import Path import requests +DATA_DIR = Path(__file__).parent.parent.parent / "data" + BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] GUILD_ID = "692816967895220344" @@ -64,7 +67,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> def main(): - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) print(f"Assigning team roles to {len(teams)} teams...") diff --git a/python/cohort/catch_up_report.py b/python/cohort/catch_up_report.py index 2eb948a..41a0d98 100644 --- a/python/cohort/catch_up_report.py +++ b/python/cohort/catch_up_report.py @@ -11,9 +11,12 @@ import json import os import subprocess 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" GITHUB_API_BASE = "https://api.github.com" @@ -440,10 +443,10 @@ def build_report( async def main() -> None: """Run the catch-up activity report.""" print("Loading data files...") - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: team_data: list[dict] = json.load(f) - with open("discord_to_github.json") as f: + with open(DATA_DIR / "discord_to_github.json") as f: discord_to_github: dict[str, str] = json.load(f) print("Getting GitHub token via gh CLI...") diff --git a/python/cohort/check_lengths.py b/python/cohort/check_lengths.py index a4a39f6..9ef5343 100644 --- a/python/cohort/check_lengths.py +++ b/python/cohort/check_lengths.py @@ -1,4 +1,15 @@ -"""Quick dry-run to check Discord message lengths before sending.""" +"""Dry-run check of Discord message lengths before sending the activity report. + +Parses the catch_up_report.md table, formats each team's data into a monospace +Discord table, and reports whether any message would exceed Discord's 2000-char limit. +Run this before send_activity_report.py to catch length issues early. + +Data files (place in data/): + - catch_up_report.md Activity report generated by catch_up_report.py + +Env vars: + - None +""" FIELDS = [ ("Discord Username", "Name", 18), @@ -11,7 +22,7 @@ FIELDS = [ ("Commits", "Commits", 7), ] -REPORT_PATH = "catch_up_report.md" +REPORT_PATH = "data/catch_up_report.md" def parse_report(path: str) -> dict[str, list[dict]]: diff --git a/python/cohort/discord_activity_checker.py b/python/cohort/discord_activity_checker.py index c23184f..e6cda78 100755 --- a/python/cohort/discord_activity_checker.py +++ b/python/cohort/discord_activity_checker.py @@ -1,6 +1,17 @@ #!/usr/bin/env python3 -"""Discord Team Activity Checker -Checks for team members who haven't sent messages in their channels within 36 hours +"""Check for team members who have not sent a message in their channel within 36 hours. + +Scans each team's Discord channel and flags members with no recent activity. +Optionally sends a direct mention message to inactive members. + +Data files (place in data/): + - team_assignments.json Team rosters with leaders and participants per team + +Outputs (written to data/): + - discord_activity_report.json Inactive members per team with last-seen timestamps + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API """ import asyncio @@ -8,16 +19,19 @@ import json import os import sys from datetime import datetime, timedelta, timezone +from pathlib import Path import aiohttp +DATA_DIR = Path(__file__).parent.parent.parent / "data" + # Configuration DISCORD_TOKEN = os.environ["DISCORD_BOT_TOKEN"] DISCORD_API_BASE = "https://discord.com/api/v10" INACTIVE_THRESHOLD_HOURS = 36 # Load team assignments from file -with open("team_assignments.json") as f: +with open(DATA_DIR / "team_assignments.json") as f: team_data = json.load(f) # Build TEAMS dictionary with channel IDs and member lists @@ -233,7 +247,7 @@ async def main(): print("\n" + "=" * 80) # Save results to JSON - with open("discord_activity_report.json", "w") as f: + with open(DATA_DIR / "discord_activity_report.json", "w") as f: json.dump( { "generated_at": datetime.now(timezone.utc).isoformat(), diff --git a/python/cohort/evaluate_technical_proficiency.py b/python/cohort/evaluate_technical_proficiency.py index b31d50b..86393ae 100644 --- a/python/cohort/evaluate_technical_proficiency.py +++ b/python/cohort/evaluate_technical_proficiency.py @@ -1,8 +1,27 @@ +"""Evaluate the technical proficiency of cohort applicants using their GitHub profiles. + +Fetches each applicant's public GitHub repositories and scores their proficiency as +Beginner, Intermediate, or Advanced based on language variety, repo count, commit +activity, and presence of certain technologies. + +Data files (place in data/): + - applicants_to_evaluate.json List of applicants with GitHub usernames + +Outputs (written to data/): + - proficiency_evaluations.json Proficiency scores and tech stacks per applicant + +Env vars: + - None (uses public GitHub API; may be rate-limited without authentication) +""" + import json import re import time import urllib.error import urllib.request +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" # GitHub API (no auth needed for public repos, but rate limited) GITHUB_API = "https://api.github.com" @@ -234,7 +253,7 @@ def evaluate_applicant(applicant: dict, index: int, total: int) -> dict: def main(): # Load applicants - with open("applicants_to_evaluate.json") as f: + with open(DATA_DIR / "applicants_to_evaluate.json") as f: applicants = json.load(f) print(f"Evaluating {len(applicants)} applicants...\n") @@ -249,7 +268,7 @@ def main(): print(f" Progress: {i + 1}/{len(applicants)} complete") # Save results - with open("proficiency_evaluations.json", "w") as f: + with open(DATA_DIR / "proficiency_evaluations.json", "w") as f: json.dump(evaluations, f, indent=2) # Summary diff --git a/python/cohort/fix_channel_permissions.py b/python/cohort/fix_channel_permissions.py index f159d7b..c257609 100644 --- a/python/cohort/fix_channel_permissions.py +++ b/python/cohort/fix_channel_permissions.py @@ -1,7 +1,15 @@ #!/usr/bin/env python3 -"""Fix permissions for cohort-team-michael-and-yoon channel. +"""Fix Send Messages / Send Messages in Threads permissions for a Discord channel. -Deny Send Messages in Threads for @everyone and @cohort. +Denies Send Messages and Send Messages in Threads for both @everyone and the +@cohort role on the target channel. Update CHANNEL_ID and COHORT_ROLE_ID before +running. + +Data files (place in data/): + - None + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API """ import asyncio diff --git a/python/cohort/generate_member_files.py b/python/cohort/generate_member_files.py index 8ce6749..663eccb 100644 --- a/python/cohort/generate_member_files.py +++ b/python/cohort/generate_member_files.py @@ -1,23 +1,46 @@ +"""Generate markdown participant and leader profile files for the cohort. + +Reads all evaluation data files and produces two markdown files summarising +each member's tech stack, availability, proficiency, and leadership assessment. + +Data files (place in data/): + - discord_verification.json Discord ID verification results (from verify_discord.py) + - proficiency_evaluations.json Proficiency scores (from evaluate_technical_proficiency.py) + - availability_analysis.json Availability UTC blocks (from analyse_availability.py) + - leadership_candidates.json List of applicants who expressed interest in leading + - leadership_evaluations.json Leadership assessment scores + +Outputs (written to data/): + - participants.md Markdown profile for each participant + - leaders.md Markdown profile for each leader candidate + +Env vars: + - None +""" + import json +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" BLOCK_EMOJIS = {"mornings": "🌅", "afternoons": "☀️", "evenings": "🌆", "nights": "🌙"} def load_all_data(): """Load all evaluation data files""" - with open("discord_verification.json") as f: + with open(DATA_DIR / "discord_verification.json") as f: verification = json.load(f) - with open("proficiency_evaluations.json") as f: + with open(DATA_DIR / "proficiency_evaluations.json") as f: proficiency = json.load(f) - with open("availability_analysis.json") as f: + with open(DATA_DIR / "availability_analysis.json") as f: availability = json.load(f) - with open("leadership_candidates.json") as f: + with open(DATA_DIR / "leadership_candidates.json") as f: candidates = json.load(f) - with open("leadership_evaluations.json") as f: + with open(DATA_DIR / "leadership_evaluations.json") as f: leadership = json.load(f) return verification, proficiency, availability, candidates, leadership @@ -230,14 +253,14 @@ def main(): participants_md = generate_participants_md( non_leader_ids, verified_usernames, prof_by_id, avail_by_id ) - with open("participants.md", "w") as f: + with open(DATA_DIR / "participants.md", "w") as f: f.write(participants_md) print(f"Generated participants.md with {len(non_leader_ids)} participants") leaders_md = generate_leaders_md( leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id ) - with open("leaders.md", "w") as f: + with open(DATA_DIR / "leaders.md", "w") as f: f.write(leaders_md) print(f"Generated leaders.md with {len(leader_ids)} leaders") diff --git a/python/cohort/generate_timeslots.py b/python/cohort/generate_timeslots.py index 9492877..2b44116 100644 --- a/python/cohort/generate_timeslots.py +++ b/python/cohort/generate_timeslots.py @@ -1,5 +1,20 @@ +"""Generate hourly timeslot JSON for use with Crabfit scheduling tool. + +Produces a list of ISO-format datetime strings covering every hour across the +scheduling window. Update the start_date and end_date constants before running. + +Outputs (written to data/): + - crabfit_timeslots.json List of hourly timeslot strings + +Env vars: + - None +""" + import json from datetime import datetime, timedelta +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" # Generate hourly time slots from Feb 1 to March 3, 2026 # 24 hours a day, America/Los_Angeles timezone @@ -18,7 +33,7 @@ print(f"First: {times[0]}") print(f"Last: {times[-1]}") # Save to file for use -with open("/home/naomi/docs/cohort/crabfit_timeslots.json", "w") as f: +with open(DATA_DIR / "crabfit_timeslots.json", "w") as f: json.dump(times, f) print("Saved to crabfit_timeslots.json") diff --git a/python/cohort/get_cohort_members.py b/python/cohort/get_cohort_members.py index 4ad2940..0b588c8 100644 --- a/python/cohort/get_cohort_members.py +++ b/python/cohort/get_cohort_members.py @@ -4,9 +4,12 @@ import asyncio import json import os +from pathlib import Path import aiohttp +DATA_DIR = Path(__file__).parent.parent.parent / "data" + DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] GUILD_ID = "692816967895220344" COHORT_ROLE_ID = "1464314780935258112" @@ -72,7 +75,7 @@ async def main() -> None: f"{i}. {member['display_name']} (@{member['username']}) - ID: {member['id']}" # noqa: E501 ) - with open("active_cohort_members.json", "w") as f: + with open(DATA_DIR / "active_cohort_members.json", "w") as f: json.dump(cohort_members, f, indent=2) print("\nSaved to active_cohort_members.json") diff --git a/python/cohort/remove_discord_roles.py b/python/cohort/remove_discord_roles.py index b0149a6..6d2b15b 100644 --- a/python/cohort/remove_discord_roles.py +++ b/python/cohort/remove_discord_roles.py @@ -1,5 +1,15 @@ #!/usr/bin/env python3 -"""Remove cohort and team roles from inactive Discord members.""" +"""Remove the Cohort and team-specific Discord roles from a list of members. + +Update INACTIVE_MEMBERS and MEMBER_TO_TEAM before running to target the correct +members. Removes both the cohort-wide role and the member's team role. + +Data files (place in data/): + - None (member IDs and team mappings are defined as constants in the script) + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API +""" import asyncio import os diff --git a/python/cohort/remove_member.py b/python/cohort/remove_member.py index acccb45..3eb27e0 100644 --- a/python/cohort/remove_member.py +++ b/python/cohort/remove_member.py @@ -18,9 +18,12 @@ 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" @@ -71,7 +74,7 @@ class MemberRemover: def find_member_info(self) -> bool: """Find which team the member is on and their role.""" - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) for team in teams: @@ -88,7 +91,7 @@ class MemberRemover: def remove_from_team_assignments(self) -> None: """Remove member from team_assignments.json.""" - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) for team in teams: @@ -103,19 +106,19 @@ class MemberRemover: f"✅ Removed from {team['name']} participants in team_assignments.json" ) - with open("team_assignments.json", "w") as f: + 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("discord_to_github.json") as f: + 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("discord_to_github.json", "w") as f: + 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") @@ -135,7 +138,7 @@ class MemberRemover: async def remove_discord_roles(self) -> None: """Remove Discord roles from the member.""" - with open("team_message_ids.json") as f: + 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: diff --git a/python/cohort/remove_resigned_members.py b/python/cohort/remove_resigned_members.py index 8c7a029..5f79cf1 100644 --- a/python/cohort/remove_resigned_members.py +++ b/python/cohort/remove_resigned_members.py @@ -6,6 +6,9 @@ before running this script. """ import json +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" # Discord IDs of members who have resigned - update before running RESIGNED_IDS: list[str] = [] @@ -13,7 +16,7 @@ RESIGNED_IDS: list[str] = [] def main() -> None: """Remove resigned members from team assignments.""" - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) changes_made = [] @@ -36,7 +39,7 @@ def main() -> None: 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: + with open(DATA_DIR / "team_assignments.json", "w") as f: json.dump(teams, f, indent=2) if changes_made: diff --git a/python/cohort/send_activity_report.py b/python/cohort/send_activity_report.py index 01b2914..4832e19 100644 --- a/python/cohort/send_activity_report.py +++ b/python/cohort/send_activity_report.py @@ -1,4 +1,14 @@ -"""Send formatted activity report tables to each team channel via Amari bot.""" +"""Send formatted activity report tables to each team's Discord channel. + +Parses catch_up_report.md and posts a monospace table of each member's Discord +and GitHub activity stats to their respective team channel. + +Data files (place in data/): + - catch_up_report.md Activity report generated by catch_up_report.py + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API +""" import asyncio import os @@ -25,7 +35,9 @@ CHANNEL_IDS = { "Sage Marigold": "1464316850669093040", } -REPORT_PATH = os.path.join(os.path.dirname(__file__), "catch_up_report.md") +REPORT_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "data", "catch_up_report.md" +) FIELDS = [ ("Discord Username", "Name", 18), diff --git a/python/cohort/send_checkin.py b/python/cohort/send_checkin.py index 26b142c..9515381 100644 --- a/python/cohort/send_checkin.py +++ b/python/cohort/send_checkin.py @@ -1,11 +1,25 @@ -"""Send check-in messages to all team channels.""" +"""Send biweekly check-in messages to all team Discord channels. + +Posts a check-in prompt to each team channel and automatically creates a thread +for responses. Members who do not respond by the deadline may be removed for +inactivity. + +Data files (place in data/): + - team_message_ids.json Channel IDs per team (generated by send_team_messages.py) + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API +""" import asyncio import json import os +from pathlib import Path import aiohttp +DATA_DIR = Path(__file__).parent.parent.parent / "data" + DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] DISCORD_API = "https://discord.com/api/v10" GUILD_ID = "692816967895220344" @@ -22,7 +36,7 @@ CHECK_IN_MESSAGE = """@everyone it is time for Naomi to do a check in. I need ** 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: + with open(DATA_DIR / "team_message_ids.json") as f: team_data = json.load(f) headers = { diff --git a/python/cohort/send_team_checkin.py b/python/cohort/send_team_checkin.py index dc66071..0fc6724 100644 --- a/python/cohort/send_team_checkin.py +++ b/python/cohort/send_team_checkin.py @@ -1,12 +1,26 @@ #!/usr/bin/env python3 -"""Send team check-in messages to all 14 teams.""" +"""Send a capacity check-in message to each team channel. + +Posts a message asking whether the team feels able to complete their project +given their current member count, and invites them to request support if needed. + +Data files (place in data/): + - team_message_ids.json Channel and role IDs per team (from send_team_messages.py) + - team_assignments.json Team rosters used to report current member counts + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API +""" import asyncio import json import os +from pathlib import Path import aiohttp +DATA_DIR = Path(__file__).parent.parent.parent / "data" + DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] NAOMI_ID = "465650873650118659" @@ -58,10 +72,10 @@ async def send_message( async def main() -> None: """Send check-in messages to all teams.""" - with open("team_message_ids.json") as f: + with open(DATA_DIR / "team_message_ids.json") as f: team_data = json.load(f) - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) async with aiohttp.ClientSession() as session: diff --git a/python/cohort/send_team_messages.py b/python/cohort/send_team_messages.py index 0ee6bb0..c9b69bb 100644 --- a/python/cohort/send_team_messages.py +++ b/python/cohort/send_team_messages.py @@ -1,10 +1,29 @@ +"""Send initial welcome and roster messages to all team Discord channels. + +Creates a pinned roster message in each team channel and stores the resulting +message ID, channel ID, and role ID in team_message_ids.json for use by +other scripts (send_checkin.py, update_roster_messages.py, etc.). + +Data files (place in data/): + - team_assignments.json Team rosters with leaders and participants per team + - applicants_to_evaluate.json Applicant data including Discord channel/role IDs + +Outputs (written to data/): + - team_message_ids.json Channel ID, message ID, and role ID per team + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API +""" + import json import os import time +from pathlib import Path import requests -# Amari's bot token +DATA_DIR = Path(__file__).parent.parent.parent / "data" + TOKEN = os.environ["DISCORD_BOT_TOKEN"] GUILD_ID = "692816967895220344" @@ -72,12 +91,12 @@ TEAMS = { } # Load team assignments and convert to dict by team name -with open("team_assignments.json") as f: +with open(DATA_DIR / "team_assignments.json") as f: team_list = json.load(f) team_data = {team["name"]: team for team in team_list} # Load applicants to get project_url by discord_id -with open("applicants_to_evaluate.json") as f: +with open(DATA_DIR / "applicants_to_evaluate.json") as f: applicants = json.load(f) applicant_lookup = {str(a["discord_id"]): a for a in applicants} diff --git a/python/cohort/update_roster_messages.py b/python/cohort/update_roster_messages.py index d181ba9..378ee41 100644 --- a/python/cohort/update_roster_messages.py +++ b/python/cohort/update_roster_messages.py @@ -3,9 +3,12 @@ import asyncio import json import os +from pathlib import Path import aiohttp +DATA_DIR = Path(__file__).parent.parent.parent / "data" + DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] GUILD_ID = "692816967895220344" @@ -62,13 +65,13 @@ def generate_roster(team: dict, discord_to_github: dict) -> str: async def main() -> None: """Update roster messages for all teams.""" - with open("team_message_ids.json") as f: + with open(DATA_DIR / "team_message_ids.json") as f: team_data = json.load(f) - with open("team_assignments.json") as f: + with open(DATA_DIR / "team_assignments.json") as f: teams = json.load(f) - with open("discord_to_github.json") as f: + with open(DATA_DIR / "discord_to_github.json") as f: discord_to_github = json.load(f) async with aiohttp.ClientSession() as session: diff --git a/python/cohort/verify_discord.py b/python/cohort/verify_discord.py index e9894c8..d4bd09a 100644 --- a/python/cohort/verify_discord.py +++ b/python/cohort/verify_discord.py @@ -1,8 +1,26 @@ +"""Verify Discord user IDs from a markdown table of applicant data. + +Reads a markdown table containing Discord IDs and checks each one against the +Discord API to confirm the user exists. Handles rate limits automatically. + +Data files (place in data/): + - table.md Markdown table of applicants including a Discord ID column + +Outputs (written to data/): + - discord_verification.json Verification result (valid/invalid) per Discord ID + +Env vars: + - DISCORD_BOT_TOKEN Bot token for the Discord API +""" + import json import os import time import urllib.error import urllib.request +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent.parent / "data" # Configuration BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] @@ -10,7 +28,7 @@ GUILD_ID = "692816967895220344" BASE_URL = "https://discord.com/api/v10" # Read Discord IDs from table.md -with open("table.md") as f: +with open(DATA_DIR / "table.md") as f: content = f.read() lines = content.strip().split("\n") @@ -104,7 +122,7 @@ print(f"Missing: {len(missing)}") print(f"Errors: {len(errors)}") # Save results -with open("discord_verification.json", "w") as f: +with open(DATA_DIR / "discord_verification.json", "w") as f: json.dump({"verified": verified, "missing": missing, "errors": errors}, f, indent=2) print("\nResults saved to discord_verification.json") diff --git a/typescript/src/crowdin/clearHiddenTranslations.ts b/typescript/src/crowdin/clearHiddenTranslations.ts index 325dff4..efb32f7 100644 --- a/typescript/src/crowdin/clearHiddenTranslations.ts +++ b/typescript/src/crowdin/clearHiddenTranslations.ts @@ -1,4 +1,15 @@ /** + * @file Delete hidden/outdated translations from a Crowdin project. + * Reads a list of string IDs from data/crowdin-strings.json, checks which + * have already been processed via the log file, and deletes translations + * for unprocessed strings across all active languages in parallel. + * Data files (place in data/): + * - crowdin-strings.json String IDs to clear (from crowdin/writeData.ts) + * - crowdin-strings-hidden.txt Log of already-processed string IDs + * Env vars: + * CROWDIN_PROJECT_ID - Crowdin project numeric ID + * CROWDIN_API_URL - Base URL of the Crowdin API + * CROWDIN_TOKEN - Crowdin personal access token. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/crowdin/reapplyTranslations.ts b/typescript/src/crowdin/reapplyTranslations.ts index 52468a9..581a014 100644 --- a/typescript/src/crowdin/reapplyTranslations.ts +++ b/typescript/src/crowdin/reapplyTranslations.ts @@ -1,4 +1,12 @@ /** + * @file Trigger a pre-translation run on a Crowdin project and wait for completion. + * Fetches all active languages and files, submits a pre-translation request, + * and polls for progress every 5 seconds until it reaches 100%%. + * Data files: None + * Env vars: + * CROWDIN_PROJECT_ID - Crowdin project numeric ID + * CROWDIN_API_URL - Base URL of the Crowdin API + * CROWDIN_TOKEN - Crowdin personal access token. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/discord/cycThreads.ts b/typescript/src/discord/cycThreads.ts index 6a752b7..8eb08fb 100644 --- a/typescript/src/discord/cycThreads.ts +++ b/typescript/src/discord/cycThreads.ts @@ -1,4 +1,10 @@ /** + * @file Create discussion threads for CYC talk submissions in a Discord channel. + * Iterates over a hardcoded list of talk titles and speakers and creates a + * Discord forum thread for each one. Update the data array and CHANNEL_ID before running. + * Data files: None (talk data is hardcoded in the script) + * Env vars: + * DISCORD_TOKEN - Bot token for the Discord API. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/discord/guildCount.ts b/typescript/src/discord/guildCount.ts index 668dd6b..36db36b 100644 --- a/typescript/src/discord/guildCount.ts +++ b/typescript/src/discord/guildCount.ts @@ -1,4 +1,14 @@ /** + * @file Count and categorise all Discord servers a user belongs to. + * Uses the Discord OAuth2 PKCE flow with a local callback server to authenticate, + * then fetches and categorises all guilds (owned, admin, moderated, partnered, + * verified, community, discoverable) and serves an HTML dashboard on localhost. + * Data files: None + * Env vars: + * DISCORD_CLIENT_ID - OAuth2 application client ID + * DISCORD_CLIENT_SECRET - OAuth2 application client secret (optional) + * DISCORD_REDIRECT_URI - OAuth2 redirect URI (default: http://127.0.0.1:8721/callback) + * DISCORD_SCOPES - OAuth2 scopes (default: identify guilds). * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/discourse/bulkUpdateCategories.ts b/typescript/src/discourse/bulkUpdateCategories.ts index 0110298..c9d5f05 100644 --- a/typescript/src/discourse/bulkUpdateCategories.ts +++ b/typescript/src/discourse/bulkUpdateCategories.ts @@ -1,4 +1,12 @@ /** + * @file Set auto-close settings on all Discourse forum categories. + * Fetches all categories and subcategories and enables auto-close based on + * last post (672 hours / 28 days) for each one. + * Data files: None + * Env vars: + * DISCOURSE_URL - Base URL of the Discourse instance + * DISCOURSE_API_KEY - Discourse API key + * DISCOURSE_API_USERNAME - Discourse API username. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/discourse/closeOldTopics.ts b/typescript/src/discourse/closeOldTopics.ts index daea1a1..410b98c 100644 --- a/typescript/src/discourse/closeOldTopics.ts +++ b/typescript/src/discourse/closeOldTopics.ts @@ -1,4 +1,11 @@ /** + * @file Close inactive Discourse topics that have had no activity for 28+ days. + * Skips topics older than 6 months (already archived) and any that are already closed. + * Data files: None + * Env vars: + * DISCOURSE_URL - Base URL of the Discourse instance + * DISCOURSE_API_KEY - Discourse API key + * DISCOURSE_API_USERNAME - Discourse API username. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/gitea/deleteFromAllRepos.ts b/typescript/src/gitea/deleteFromAllRepos.ts index 0814539..421e959 100644 --- a/typescript/src/gitea/deleteFromAllRepos.ts +++ b/typescript/src/gitea/deleteFromAllRepos.ts @@ -1,4 +1,10 @@ /** + * @file Delete a file from every repository across all nhcarrigan Gitea orgs. + * Prompts for the file path to delete, then removes it from every repo + * across nhcarrigan, nhcarrigan-private, and nhcarrigan-games. + * Data files: None + * Env vars: + * GITEA_TOKEN - Gitea personal access token with repo write permissions. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/gitea/uploadToAllRepos.ts b/typescript/src/gitea/uploadToAllRepos.ts index 74a0dc6..8d0f304 100644 --- a/typescript/src/gitea/uploadToAllRepos.ts +++ b/typescript/src/gitea/uploadToAllRepos.ts @@ -1,4 +1,11 @@ /** + * @file Upload a file from data/ to every repository across all nhcarrigan Gitea orgs. + * Prompts for the local filename and destination path, then creates or updates + * the file in every repo across nhcarrigan, nhcarrigan-private, and nhcarrigan-games. + * Data files (place in data/): + * - Any file to upload (prompted interactively) + * Env vars: + * GITEA_TOKEN - Gitea personal access token with repo write permissions. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/gitea/uploadToReposConditionally.ts b/typescript/src/gitea/uploadToReposConditionally.ts index 90f540c..a93d3b8 100644 --- a/typescript/src/gitea/uploadToReposConditionally.ts +++ b/typescript/src/gitea/uploadToReposConditionally.ts @@ -1,4 +1,11 @@ /** + * @file Conditionally upload a file to Gitea repos based on whether another file exists. + * Prompts for the file to upload, a condition file path, and whether to upload + * when the condition file exists or does not exist. + * Data files (place in data/): + * - Any file to upload (prompted interactively) + * Env vars: + * GITEA_TOKEN - Gitea personal access token with repo write permissions. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/github/auditNpmPackages.ts b/typescript/src/github/auditNpmPackages.ts index 5a65d06..dda69f5 100644 --- a/typescript/src/github/auditNpmPackages.ts +++ b/typescript/src/github/auditNpmPackages.ts @@ -1,4 +1,12 @@ /** + * @file Audit npm packages across a GitHub organisation for known vulnerabilities. + * Lists all repositories in the target org, fetches each package.json, and checks + * for usage of packages in the hardcoded vulnerable package list. + * Update the vulnerable package list and orgName constant before running. + * Outputs (written to data/): + * - npm-vulnerabilities.txt Repos and packages with vulnerability findings + * Env vars: + * GITHUB_TOKEN - GitHub personal access token with org read permissions. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/github/onboardMentee.ts b/typescript/src/github/onboardMentee.ts index 0c704ef..5129925 100644 --- a/typescript/src/github/onboardMentee.ts +++ b/typescript/src/github/onboardMentee.ts @@ -1,4 +1,12 @@ /** + * @file Onboard a new mentee to the nhcarrigan-mentorship GitHub organisation. + * Prompts for the mentee's Discord ID, full name, and GitHub username, creates + * a personal repository in the org, adds the mentee as a collaborator with + * maintain permissions, and posts a welcome message to the Discord channel. + * Data files: None + * Env vars: + * GITHUB_TOKEN - GitHub personal access token with org and repo permissions + * DISCORD_TOKEN - Bot token for the Discord API. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/github/postUserStories.ts b/typescript/src/github/postUserStories.ts index c2cc618..4e85f64 100644 --- a/typescript/src/github/postUserStories.ts +++ b/typescript/src/github/postUserStories.ts @@ -1,4 +1,11 @@ /** + * @file Post user story markdown files as GitHub issue descriptions. + * Reads all markdown files from data/stories/ and updates the corresponding + * GitHub issue with that content. Filename format: {repo-name}-{issue-number}.md. + * Data files (place in data/stories/): + * - {repo-name}-{issue-number}.md User story content for each issue + * Env vars: + * GITHUB_TOKEN - GitHub personal access token with repo write permissions. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/music/id3v2.ts b/typescript/src/music/id3v2.ts index 1055f89..d06392b 100644 --- a/typescript/src/music/id3v2.ts +++ b/typescript/src/music/id3v2.ts @@ -1,4 +1,10 @@ /** + * @file Tag MP3 files with ID3v2 metadata and album art for Neuro-sama tracks. + * Reads all MP3 files from the download directory, extracts the title from + * the filename, sets the artist to "Neuro-sama", and applies the cover image. + * Update the directory and cover constants before running. + * Data files: None (reads from a local directory path defined in the script) + * Env vars: None (uses eyeD3 and id3v2 CLI tools). * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/s3/bulkUpload.ts b/typescript/src/s3/bulkUpload.ts index c7ef695..d9341fb 100644 --- a/typescript/src/s3/bulkUpload.ts +++ b/typescript/src/s3/bulkUpload.ts @@ -1,4 +1,13 @@ /** + * @file Upload all files in the data/ directory to an S3-compatible bucket. + * Displays a tree of files to upload, prompts for confirmation, then uploads + * with a progress bar showing per-file status. + * Data files (place in data/): + * - All files to upload are read from the data/ directory recursively + * Env vars: + * AWS_ACCESS_KEY_ID - S3 access key + * AWS_SECRET_ACCESS_KEY - S3 secret key + * S3_ENDPOINT - S3-compatible endpoint URL. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/s3/correctContentType.ts b/typescript/src/s3/correctContentType.ts index 29a8824..a97ebfc 100644 --- a/typescript/src/s3/correctContentType.ts +++ b/typescript/src/s3/correctContentType.ts @@ -1,4 +1,12 @@ /** + * @file Fix incorrect Content-Type metadata on objects in an S3-compatible bucket. + * Lists all objects, detects MIME type from extension, and re-uploads metadata + * for any file whose stored Content-Type does not match the expected type. + * Data files: None + * Env vars: + * AWS_ACCESS_KEY_ID - S3 access key + * AWS_SECRET_ACCESS_KEY - S3 secret key + * S3_ENDPOINT - S3-compatible endpoint URL. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/s3/deleteContents.ts b/typescript/src/s3/deleteContents.ts index a145600..cd30454 100644 --- a/typescript/src/s3/deleteContents.ts +++ b/typescript/src/s3/deleteContents.ts @@ -1,4 +1,12 @@ /** + * @file Delete all objects from an S3-compatible bucket. + * Prompts for the bucket name, requires double confirmation (type name + yes/no), + * then lists and deletes all objects in batches with a progress bar. + * Data files: None + * Env vars: + * AWS_ACCESS_KEY_ID - S3 access key + * AWS_SECRET_ACCESS_KEY - S3 secret key + * S3_ENDPOINT - S3-compatible endpoint URL. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/s3/upload.ts b/typescript/src/s3/upload.ts index d046285..233c417 100644 --- a/typescript/src/s3/upload.ts +++ b/typescript/src/s3/upload.ts @@ -1,4 +1,12 @@ /** + * @file Upload a single file from the data/ directory to an S3-compatible bucket. + * Prompts for the local filename and the destination path in the bucket. + * Data files (place in data/): + * - Any file to upload (prompted interactively) + * Env vars: + * AWS_ACCESS_KEY_ID - S3 access key + * AWS_SECRET_ACCESS_KEY - S3 secret key + * S3_ENDPOINT - S3-compatible endpoint URL. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan diff --git a/typescript/src/security/generateReport.ts b/typescript/src/security/generateReport.ts index f5aa469..8dbac63 100644 --- a/typescript/src/security/generateReport.ts +++ b/typescript/src/security/generateReport.ts @@ -1,5 +1,12 @@ /* eslint-disable max-lines -- Necessary for all of the HTML templating. */ /** + * @file Generate a public HTML security report from DefectDojo findings. + * Fetches all active, verified findings from DefectDojo and produces a styled + * HTML dashboard grouped by project with severity counts. + * Outputs (written to data/): + * - public_security_report.html HTML security report + * Env vars: + * DOJO_TOKEN - DefectDojo API token. * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan -- 2.52.0 From c4b9f795d9b95e549435284c141dc17fa2493a1e Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 23 Feb 2026 15:57:01 -0800 Subject: [PATCH 3/5] chore: add documentation todo tracker --- DOCS_TODO.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 DOCS_TODO.md diff --git a/DOCS_TODO.md b/DOCS_TODO.md new file mode 100644 index 0000000..43bd460 --- /dev/null +++ b/DOCS_TODO.md @@ -0,0 +1,86 @@ +# Documentation TODO + +## Plan + +Add a `README.md` to each script category folder. Each README should document every script in that folder with: +- What the script does (1-2 sentences) +- Data files required (filename, what it contains, where to put it - top-level `data/`) +- Environment variables required + +## Categories to Document + +### TypeScript + +- `typescript/src/crowdin/README.md` + - `clearHiddenTranslations.ts` + - `reapplyTranslations.ts` + - `writeData.ts` + +- `typescript/src/discord/README.md` + - `cycThreads.ts` + - `guildCount.ts` + +- `typescript/src/discourse/README.md` + - `bulkUpdateCategories.ts` + - `closeOldTopics.ts` + +- `typescript/src/gitea/README.md` + - `deleteFromAllRepos.ts` + - `uploadToAllRepos.ts` + - `uploadToReposConditionally.ts` + +- `typescript/src/github/README.md` + - `auditNpmPackages.ts` + - `onboardMentee.ts` + - `postUserStories.ts` + +- `typescript/src/music/README.md` + - `id3v2.ts` + +- `typescript/src/s3/README.md` + - `bulkUpload.ts` + - `correctContentType.ts` + - `deleteContents.ts` + - `upload.ts` + +- `typescript/src/security/README.md` + - `generateReport.ts` + +### Python + +- `python/cohort/README.md` + - `add_github_team_members.py` + - `analyse_availability.py` + - `assign_cohort_role.py` + - `assign_team_roles.py` + - `catch_up_report.py` + - `check_channel_permissions.py` + - `check_lengths.py` + - `check_member_status.py` + - `create_team_voice_channels.py` + - `discord_activity_checker.py` + - `evaluate_technical_proficiency.py` + - `fetch_roster.py` + - `fix_channel_permissions.py` + - `generate_member_files.py` + - `generate_timeslots.py` + - `get_cohort_members.py` + - `list_all_guild_roles.py` + - `list_discord_roles.py` + - `remove_discord_roles.py` + - `remove_member.py` + - `remove_resigned_members.py` + - `send_activity_report.py` + - `send_checkin.py` + - `send_team_checkin.py` + - `send_team_messages.py` + - `update_cohort_leads_permissions.py` + - `update_roster_messages.py` + - `verify_discord.py` + +## Notes + +- All data files go in the top-level `data/` directory +- Python scripts resolve `data/` via `DATA_DIR = Path(__file__).parent.parent.parent / "data"` +- TypeScript scripts resolve `data/` via `join(import.meta.dirname, "..", "..", "data")` +- Each README should have a quick "Getting Started" section explaining how to run scripts (via `run.sh` or the Makefile) -- 2.52.0 From b0620f2af385f165eff47f72d41c49cb6388b397 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Feb 2026 20:00:44 -0800 Subject: [PATCH 4/5] refactor: reorganise bash scripts into subdirectories and add bash runner support Move yubikey scripts from bash/ root into bash/yubikey/, move cohort shell scripts from python/cohort/ into bash/cohort/, and update run.sh to support Bash as a third language with category-based script discovery. --- .../cohort/remove_github_members.sh | 0 .../cohort/update_github_teams.sh | 0 bash/{ => yubikey}/add-keys-to-git.sh | 0 bash/{ => yubikey}/fix-yubikey-perms.sh | 0 bash/{ => yubikey}/list-yubikey-ssh-keys.sh | 0 run.sh | 28 +++++++++++-------- 6 files changed, 16 insertions(+), 12 deletions(-) rename {python => bash}/cohort/remove_github_members.sh (100%) rename {python => bash}/cohort/update_github_teams.sh (100%) rename bash/{ => yubikey}/add-keys-to-git.sh (100%) rename bash/{ => yubikey}/fix-yubikey-perms.sh (100%) rename bash/{ => yubikey}/list-yubikey-ssh-keys.sh (100%) diff --git a/python/cohort/remove_github_members.sh b/bash/cohort/remove_github_members.sh similarity index 100% rename from python/cohort/remove_github_members.sh rename to bash/cohort/remove_github_members.sh diff --git a/python/cohort/update_github_teams.sh b/bash/cohort/update_github_teams.sh similarity index 100% rename from python/cohort/update_github_teams.sh rename to bash/cohort/update_github_teams.sh diff --git a/bash/add-keys-to-git.sh b/bash/yubikey/add-keys-to-git.sh similarity index 100% rename from bash/add-keys-to-git.sh rename to bash/yubikey/add-keys-to-git.sh diff --git a/bash/fix-yubikey-perms.sh b/bash/yubikey/fix-yubikey-perms.sh similarity index 100% rename from bash/fix-yubikey-perms.sh rename to bash/yubikey/fix-yubikey-perms.sh diff --git a/bash/list-yubikey-ssh-keys.sh b/bash/yubikey/list-yubikey-ssh-keys.sh similarity index 100% rename from bash/list-yubikey-ssh-keys.sh rename to bash/yubikey/list-yubikey-ssh-keys.sh diff --git a/run.sh b/run.sh index 24b6948..7f48294 100755 --- a/run.sh +++ b/run.sh @@ -96,7 +96,7 @@ select_option() { # Step 1: Select Language echo "" -languages=("TypeScript" "Python") +languages=("TypeScript" "Python" "Bash") select_option "Select a language:" "${languages[@]}" lang_index=$? language="${languages[$lang_index]}" @@ -109,15 +109,16 @@ if [ "$language" == "TypeScript" ]; then runner="pnpm tsx" # Get subdirectories as categories (excluding utils and interfaces) mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'utils' ! -name 'interfaces' -exec basename {} \; | sort) -else +elif [ "$language" == "Python" ]; then script_dir="python" runner="uv run python" # Get subdirectories as categories (excluding __pycache__ and .venv) mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' ! -name '.venv' ! -name '*.egg-info' -exec basename {} \; | sort) - # Add "Root Scripts" option for Python files in root - if ls "$script_dir"/*.py &>/dev/null 2>&1; then - categories=("Root Scripts" "${categories[@]}") - fi +else + script_dir="bash" + runner="bash" + # Get subdirectories as categories + mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort) fi if [ ${#categories[@]} -eq 0 ]; then @@ -132,12 +133,12 @@ category="${categories[$cat_index]}" echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$category${RESET}\n" # Step 3: Get scripts in category -if [ "$category" == "Root Scripts" ]; then - search_dir="$script_dir" - mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort) -elif [ "$language" == "TypeScript" ]; then +if [ "$language" == "TypeScript" ]; then search_dir="$script_dir/$category" mapfile -t scripts < <(find "$search_dir" -name "*.ts" -exec basename {} \; | sort) +elif [ "$language" == "Bash" ]; then + search_dir="$script_dir/$category" + mapfile -t scripts < <(find "$search_dir" -name "*.sh" -exec basename {} \; | sort) else search_dir="$script_dir/$category" mapfile -t scripts < <(find "$search_dir" -name "*.py" ! -name "__init__.py" -exec basename {} \; | sort) @@ -155,8 +156,8 @@ script="${scripts[$script_index]}" echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$script${RESET}\n" # Build the full script path -if [ "$category" == "Root Scripts" ]; then - script_path="$script" +if [ "$language" == "Bash" ]; then + script_path="bash/$category/$script" elif [ "$language" == "TypeScript" ]; then script_path="src/$category/$script" else @@ -178,6 +179,9 @@ if [ "$language" == "TypeScript" ]; then cd typescript echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n" op run --env-file=../prod.env --no-masking -- $runner "$script_path" +elif [ "$language" == "Bash" ]; then + echo -e " ${DIM}$ $runner $script_path${RESET}\n" + $runner "$script_path" else cd python echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n" -- 2.52.0 From ae081cb54c4fa760a6740a790c0f2f433144536f Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Feb 2026 20:00:51 -0800 Subject: [PATCH 5/5] docs: add README files for all script categories and update project docs Add Getting Started sections and correct usage commands to all category READMEs (TypeScript, Python, Bash). Update top-level README.md and CLAUDE.md to reflect the Bash language, correct project structure, and accurate make run instructions. Remove completed DOCS_TODO.md. --- CLAUDE.md | 77 ++- DOCS_TODO.md | 86 --- README.md | 150 +++-- bash/cohort/README.md | 75 +++ bash/yubikey/README.md | 109 ++++ python/cohort/README.md | 883 +++++++++++++++++++++++++++++ typescript/src/crowdin/README.md | 125 ++++ typescript/src/discord/README.md | 87 +++ typescript/src/discourse/README.md | 84 +++ typescript/src/gitea/README.md | 124 ++++ typescript/src/github/README.md | 124 ++++ typescript/src/music/README.md | 48 ++ typescript/src/s3/README.md | 163 ++++++ typescript/src/security/README.md | 53 ++ 14 files changed, 1985 insertions(+), 203 deletions(-) delete mode 100644 DOCS_TODO.md create mode 100644 bash/cohort/README.md create mode 100644 bash/yubikey/README.md create mode 100644 python/cohort/README.md create mode 100644 typescript/src/crowdin/README.md create mode 100644 typescript/src/discord/README.md create mode 100644 typescript/src/discourse/README.md create mode 100644 typescript/src/gitea/README.md create mode 100644 typescript/src/github/README.md create mode 100644 typescript/src/music/README.md create mode 100644 typescript/src/s3/README.md create mode 100644 typescript/src/security/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 6d24e56..66dafe7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,32 +4,37 @@ This document contains project-specific instructions for working with the Epheme ## Project Overview -Ephemere is a collection of ephemeral scripts for various tasks, written in TypeScript and Python. It contains utilities for: +Ephemere is a collection of ephemeral scripts for various tasks, written in TypeScript, Python, and Bash. It contains utilities for: - S3 operations (upload, bulk upload, delete, content type correction) - Discord bot utilities - Discourse forum management - Gitea/GitHub operations - Security analysis tools - Music-related scripts -- Various utility functions +- Cohort programme management (Python + Bash) +- YubiKey SSH key and permission utilities (Bash) ## Project Structure ``` ephemere/ ├── typescript/ # TypeScript scripts -│ ├── src/ -│ │ ├── s3/ # S3 operations (upload, delete, bulk operations) -│ │ ├── discord/ # Discord bot and utilities -│ │ ├── discourse/ # Discourse forum management -│ │ ├── gitea/ # Gitea API interactions -│ │ ├── github/ # GitHub API interactions -│ │ ├── security/ # Security analysis tools -│ │ ├── music/ # Music-related utilities -│ │ └── utils/ # Shared utilities -│ └── data/ # Data files for S3 uploads -├── python/ # Python scripts -└── prod.env # 1Password vault references (safe to commit) +│ └── src/ +│ ├── s3/ # S3 operations (upload, delete, bulk operations) +│ ├── discord/ # Discord bot and utilities +│ ├── discourse/ # Discourse forum management +│ ├── gitea/ # Gitea API interactions +│ ├── github/ # GitHub API interactions +│ ├── security/ # Security analysis tools +│ ├── music/ # Music-related utilities +│ └── utils/ # Shared utilities +├── python/ +│ └── cohort/ # Cohort programme management scripts +├── bash/ +│ ├── cohort/ # GitHub team management for cohorts +│ └── yubikey/ # YubiKey SSH key and permission utilities +├── data/ # Input/output data files (gitignored) +└── prod.env # 1Password vault references (safe to commit) ``` ## Development Standards @@ -37,13 +42,20 @@ ephemere/ ### TypeScript Scripts - All TypeScript scripts must follow Naomi's Node.js project standards - Use `@nhcarrigan/typescript-config` and `@nhcarrigan/eslint-config` -- Run scripts using the Makefile: `make run-ts src/path/to/script.ts` +- Run scripts using the interactive runner: `make run` (select TypeScript → category → script) - Interactive scripts should use `@inquirer/prompts` for user input ### Python Scripts - Use `uv` for package management - Linting and formatting with `ruff` -- Run scripts using the Makefile: `make run-py script_name.py` +- Scripts live in subdirectories of `python/` (e.g. `python/cohort/`) +- Run scripts using the interactive runner: `make run` (select Python → category → script) + +### Bash Scripts +- Scripts live in subdirectories of `bash/` (e.g. `bash/cohort/`, `bash/yubikey/`) +- Run scripts using the interactive runner: `make run` (select Bash → category → script) +- Or run directly: `bash bash//