From c0ad74367a1380e261ecd39dae86b8e82aa849df Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Fri, 23 Jan 2026 15:32:02 -0800 Subject: [PATCH 1/5] feat: add multilingual support so Naomi can use Python too --- .gitea/workflows/ci.yml | 94 ++++--- .gitignore | 17 +- Makefile | 75 ++++++ README.md | 132 +++++++++- python/.gitignore | 56 +++++ python/Makefile | 28 +++ python/cohort/analyse_availability.py | 182 ++++++++++++++ .../cohort/evaluate_technical_proficiency.py | 238 ++++++++++++++++++ python/pyproject.toml | 74 ++++++ python/requirements.txt | 2 + python/uv.lock | 43 ++++ run.sh | 194 ++++++++++++++ typescript/.gitignore | 19 ++ typescript/Makefile | 27 ++ .../eslint.config.js | 0 package.json => typescript/package.json | 5 +- pnpm-lock.yaml => typescript/pnpm-lock.yaml | 0 .../src}/crowdin/clearHiddenTranslations.ts | 0 .../src}/crowdin/interfaces/file.ts | 0 .../src}/crowdin/interfaces/preTranslation.ts | 0 .../src}/crowdin/interfaces/project.ts | 0 .../src}/crowdin/interfaces/string.ts | 0 .../src}/crowdin/reapplyTranslations.ts | 0 .../src}/crowdin/utils/getFiles.ts | 0 .../src}/crowdin/utils/getLanguages.ts | 0 .../src}/crowdin/utils/getStrings.ts | 0 {src => typescript/src}/crowdin/writeData.ts | 0 {src => typescript/src}/discord/cycThreads.ts | 0 {src => typescript/src}/discord/guildCount.ts | 0 .../src}/discourse/bulkUpdateCategories.ts | 0 .../src}/discourse/closeOldTopics.ts | 0 .../src}/gitea/deleteFromAllRepos.ts | 0 .../src}/gitea/uploadToAllRepos.ts | 0 .../src}/gitea/uploadToReposConditionally.ts | 0 .../src}/github/auditNpmPackages.ts | 0 .../src}/github/postUserStories.ts | 0 {src => typescript/src}/index.ts | 0 .../src}/interfaces/discourse.ts | 0 {src => typescript/src}/interfaces/dojo.ts | 0 {src => typescript/src}/interfaces/gitea.ts | 0 {src => typescript/src}/music/id3v2.ts | 0 {src => typescript/src}/s3/bulkUpload.ts | 0 .../src}/s3/correctContentType.ts | 0 {src => typescript/src}/s3/upload.ts | 0 .../src}/security/generateReport.ts | 0 .../src}/utils/backoffAndRetry.ts | 0 typescript/src/utils/interactive-runner.ts | 165 ++++++++++++ {src => typescript/src}/utils/mimeType.ts | 0 .../src}/utils/paginatedFetch.ts | 0 .../src}/utils/serialiseJsonOrError.ts | 0 {src => typescript/src}/utils/sleep.ts | 0 tsconfig.json => typescript/tsconfig.json | 0 52 files changed, 1305 insertions(+), 46 deletions(-) create mode 100644 Makefile create mode 100644 python/.gitignore create mode 100644 python/Makefile create mode 100644 python/cohort/analyse_availability.py create mode 100644 python/cohort/evaluate_technical_proficiency.py create mode 100644 python/pyproject.toml create mode 100644 python/requirements.txt create mode 100644 python/uv.lock create mode 100755 run.sh create mode 100644 typescript/.gitignore create mode 100644 typescript/Makefile rename eslint.config.js => typescript/eslint.config.js (100%) rename package.json => typescript/package.json (76%) rename pnpm-lock.yaml => typescript/pnpm-lock.yaml (100%) rename {src => typescript/src}/crowdin/clearHiddenTranslations.ts (100%) rename {src => typescript/src}/crowdin/interfaces/file.ts (100%) rename {src => typescript/src}/crowdin/interfaces/preTranslation.ts (100%) rename {src => typescript/src}/crowdin/interfaces/project.ts (100%) rename {src => typescript/src}/crowdin/interfaces/string.ts (100%) rename {src => typescript/src}/crowdin/reapplyTranslations.ts (100%) rename {src => typescript/src}/crowdin/utils/getFiles.ts (100%) rename {src => typescript/src}/crowdin/utils/getLanguages.ts (100%) rename {src => typescript/src}/crowdin/utils/getStrings.ts (100%) rename {src => typescript/src}/crowdin/writeData.ts (100%) rename {src => typescript/src}/discord/cycThreads.ts (100%) rename {src => typescript/src}/discord/guildCount.ts (100%) rename {src => typescript/src}/discourse/bulkUpdateCategories.ts (100%) rename {src => typescript/src}/discourse/closeOldTopics.ts (100%) rename {src => typescript/src}/gitea/deleteFromAllRepos.ts (100%) rename {src => typescript/src}/gitea/uploadToAllRepos.ts (100%) rename {src => typescript/src}/gitea/uploadToReposConditionally.ts (100%) rename {src => typescript/src}/github/auditNpmPackages.ts (100%) rename {src => typescript/src}/github/postUserStories.ts (100%) rename {src => typescript/src}/index.ts (100%) rename {src => typescript/src}/interfaces/discourse.ts (100%) rename {src => typescript/src}/interfaces/dojo.ts (100%) rename {src => typescript/src}/interfaces/gitea.ts (100%) rename {src => typescript/src}/music/id3v2.ts (100%) rename {src => typescript/src}/s3/bulkUpload.ts (100%) rename {src => typescript/src}/s3/correctContentType.ts (100%) rename {src => typescript/src}/s3/upload.ts (100%) rename {src => typescript/src}/security/generateReport.ts (100%) rename {src => typescript/src}/utils/backoffAndRetry.ts (100%) create mode 100644 typescript/src/utils/interactive-runner.ts rename {src => typescript/src}/utils/mimeType.ts (100%) rename {src => typescript/src}/utils/paginatedFetch.ts (100%) rename {src => typescript/src}/utils/serialiseJsonOrError.ts (100%) rename {src => typescript/src}/utils/sleep.ts (100%) rename tsconfig.json => typescript/tsconfig.json (100%) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index da6aa1a..e6355cb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,47 +1,81 @@ -name: Node.js CI +name: CI + on: push: - branches: - - main + branches: [main] pull_request: - branches: - - main + branches: [main] jobs: - ci: - name: CI + dependency-pin-check-typescript: runs-on: ubuntu-latest - steps: - - name: Checkout Source Files - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - name: Copy TypeScript files to root + run: | + cp typescript/package.json . + cp typescript/package-lock.json . 2>/dev/null || true + cp typescript/pnpm-lock.yaml . 2>/dev/null || true + - uses: naomi-lgbt/dependency-pin-check@main - - name: Use Node.js v24 - uses: actions/setup-node@v4 + dependency-pin-check-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Copy Python files to root + run: | + cp python/requirements.txt . + cp python/pyproject.toml . + - uses: naomi-lgbt/dependency-pin-check@main + + typescript: + needs: dependency-pin-check-typescript + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + + - uses: actions/setup-node@v4 with: node-version: 24 + cache: 'pnpm' + cache-dependency-path: typescript/pnpm-lock.yaml - - name: Setup pnpm - uses: pnpm/action-setup@v2 + - name: Install dependencies + run: make install-ts + + - name: Run ESLint + run: make lint-ts + + - name: Build TypeScript + run: make build + + - name: Run tests + run: make test + + python: + needs: dependency-pin-check-python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 with: - version: 10 + python-version: '3.12' - - name: Ensure Dependencies are Pinned - uses: naomi-lgbt/dependency-pin-check@main + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - language: javascript - dev-dependencies: true - peer-dependencies: true - optional-dependencies: true + enable-cache: true - - name: Install Dependencies - run: pnpm install + - name: Install dependencies + run: make install-py - - name: Lint Source Files - run: pnpm run lint + - name: Run Ruff linter + run: make lint-py - - name: Verify Build - run: pnpm run build - - - name: Run Tests - run: pnpm run test + - name: Check Ruff formatting + run: make format-check-py \ No newline at end of file diff --git a/.gitignore b/.gitignore index a08a1ee..910e4c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,17 @@ -node_modules +# Project-specific prod data -!data/.gitkeep \ No newline at end of file +!data/.gitkeep + +# Environment files +.env +.env.local +prod.env + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9581171 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test clean run + +# Default target - show help +help: + @echo "Available commands:" + @echo " make install - Install all dependencies (TypeScript and Python)" + @echo " make install-ts - Install TypeScript dependencies only" + @echo " make install-py - Install Python dependencies only" + @echo " make build - Build TypeScript (type check)" + @echo " make lint - Run all linters (TypeScript and Python)" + @echo " make lint-ts - Run TypeScript linter only" + @echo " make lint-py - Run Python linter only" + @echo " make format - Format Python code" + @echo " make format-check - Check Python formatting without modifying" + @echo " make test - Run tests" + @echo " make clean - Clean build artifacts and caches" + @echo "" + @echo "Running scripts:" + @echo " make run - Interactive script runner (select language, category, script)" + +# Install all dependencies +install: install-ts install-py + +# TypeScript dependencies +install-ts: + cd typescript && pnpm install --frozen-lockfile + +# Python dependencies +install-py: + cd python && uv venv + cd python && uv pip install -r requirements.txt + +# Build TypeScript +build: + cd typescript && pnpm exec tsc --noEmit + +# Run all linters +lint: lint-ts lint-py + +# TypeScript linting +lint-ts: + cd typescript && pnpm exec eslint src --max-warnings 0 + +# Python linting +lint-py: + cd python && uv run ruff check . + +# Format Python code +format: format-py + +format-py: + cd python && uv run ruff format . + +# Check formatting without modifying +format-check: format-check-py + +format-check-py: + cd python && uv run ruff format --check . + +# Run tests +test: + @echo "No tests configured yet" + @exit 0 + +# Clean build artifacts and caches +clean: + rm -rf typescript/node_modules + rm -rf python/.venv + rm -rf python/.ruff_cache + find python -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find python -type f -name "*.pyc" -delete 2>/dev/null || true + +# Interactive script runner +run: + @./run.sh \ No newline at end of file diff --git a/README.md b/README.md index 47a840e..b5ae541 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,132 @@ -# New Repository Template +# Ephemere -This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository. +A collection of ephemeral scripts for various tasks, written in TypeScript and Python. -If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose. +## Project Structure -## Readme +``` +. +├── typescript/ # TypeScript project +│ ├── src/ # TypeScript source files +│ ├── package.json +│ ├── tsconfig.json +│ └── eslint.config.js +├── python/ # Python project +│ ├── *.py # Python scripts +│ ├── pyproject.toml +│ └── requirements.txt +├── Makefile # Build commands for both projects +└── README.md +``` -Delete all of the above text (including this line), and uncomment the below text to use our standard readme template. +## Setup - +We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. \ No newline at end of file diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..598102c --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual Environment +.venv/ +venv/ +env/ +ENV/ + +# uv +.uv/ + +# Distribution / packaging +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Testing +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +htmlcov/ + +# Linting +.ruff_cache/ +.mypy_cache/ +.pylintrc + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/python/Makefile b/python/Makefile new file mode 100644 index 0000000..aa6c3d1 --- /dev/null +++ b/python/Makefile @@ -0,0 +1,28 @@ +.PHONY: help install lint format format-check clean + +help: + @echo "Python project commands:" + @echo " make install - Set up virtual environment and install dependencies" + @echo " make lint - Run Ruff linter" + @echo " make format - Format code with Ruff" + @echo " make format-check - Check formatting without modifying" + @echo " make clean - Clean Python artifacts" + +install: + uv venv + uv pip install -r requirements.txt + +lint: + uv run ruff check . + +format: + uv run ruff format . + +format-check: + uv run ruff format --check . + +clean: + rm -rf .venv + rm -rf .ruff_cache + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true \ No newline at end of file diff --git a/python/cohort/analyse_availability.py b/python/cohort/analyse_availability.py new file mode 100644 index 0000000..18e5330 --- /dev/null +++ b/python/cohort/analyse_availability.py @@ -0,0 +1,182 @@ +import json +import re +from collections import defaultdict + +DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + +UTC_BLOCKS = { + 'mornings': (6, 12), # 06:00 - 12:00 UTC + 'afternoons': (12, 18), # 12:00 - 18:00 UTC + 'evenings': (18, 24), # 18:00 - 00:00 UTC + 'nights': (0, 6) # 00:00 - 06:00 UTC +} + +def parse_utc_offset(timezone_str: str) -> float: + """Extract UTC offset from timezone string like 'America/New_York (UTC-5)'""" + match = re.search(r'UTC([+-]?\d+(?::\d+)?)', timezone_str) + if match: + offset_str = match.group(1) + if ':' in offset_str: + parts = offset_str.split(':') + hours = int(parts[0]) + minutes = int(parts[1]) if len(parts) > 1 else 0 + if hours < 0: + return hours - minutes / 60 + return hours + minutes / 60 + return float(offset_str) + return 0 + +def parse_time_slots(time_str: str) -> list[tuple[int, int]]: + """Parse time slots like '17:00-18:00' or '07:00-08:00; 19:00-20:00'""" + slots = [] + if not time_str or time_str.lower() in ['n/a', 'na', '']: + return slots + + parts = time_str.split(';') + for part in parts: + part = part.strip() + match = re.search(r'(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})', part) + if match: + start_hour = int(match.group(1)) + end_hour = int(match.group(3)) + slots.append((start_hour, end_hour)) + return slots + +def local_hour_to_utc(local_hour: int, utc_offset: float) -> int: + """Convert local hour to UTC hour""" + utc_hour = local_hour - utc_offset + return int(utc_hour) % 24 + +def get_utc_blocks_for_hour(utc_hour: int) -> list[str]: + """Determine which UTC block(s) an hour falls into""" + blocks = [] + for block_name, (start, end) in UTC_BLOCKS.items(): + if block_name == 'nights': + if utc_hour >= 0 and utc_hour < 6: + blocks.append(block_name) + elif block_name == 'evenings': + if utc_hour >= 18 and utc_hour < 24: + blocks.append(block_name) + else: + if utc_hour >= start and utc_hour < end: + blocks.append(block_name) + return blocks + +def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict: + """Analyze availability for one applicant""" + utc_offset = parse_utc_offset(timezone_str) + + block_counts = defaultdict(int) + all_utc_hours = set() + + for day in DAYS: + slots = day_slots.get(day, []) + for start_hour, end_hour in slots: + for hour in range(start_hour, end_hour): + utc_hour = local_hour_to_utc(hour, utc_offset) + all_utc_hours.add(utc_hour) + blocks = get_utc_blocks_for_hour(utc_hour) + for block in blocks: + block_counts[block] += 1 + + available_blocks = [] + for block in ['mornings', 'afternoons', 'evenings', 'nights']: + if block_counts[block] >= 3: + available_blocks.append(block) + + return { + 'utc_offset': utc_offset, + 'timezone': timezone_str, + 'available_blocks': available_blocks, + 'block_counts': dict(block_counts), + 'total_unique_utc_hours': len(all_utc_hours) + } + +def parse_table_md() -> list[dict]: + """Parse table.md and extract availability data""" + with open('table.md', 'r') as f: + content = f.read() + + lines = content.strip().split('\n') + + header_idx = None + for i, line in enumerate(lines): + if line.startswith('| Discord ID'): + header_idx = i + break + + if header_idx is None: + raise ValueError("Could not find table header") + + headers = [h.strip() for h in lines[header_idx].split('|')[1:-1]] + + applicants = [] + for line in lines[header_idx + 2:]: + if not line.startswith('|'): + continue + + cells = [c.strip() for c in line.split('|')[1:-1]] + if len(cells) < len(headers): + continue + + row = dict(zip(headers, cells)) + applicants.append(row) + + return applicants + +def main(): + with open('discord_verification.json', 'r') as f: + verification = json.load(f) + + verified_ids = set(v[0] for v in verification['verified']) + print(f"Verified applicants: {len(verified_ids)}") + + applicants = parse_table_md() + print(f"Total applicants in table: {len(applicants)}") + + availability_results = [] + + for applicant in applicants: + discord_id = applicant.get('Discord ID', '') + if discord_id not in verified_ids: + continue + + timezone = applicant.get('Timezone', '') + + day_slots = {} + for day in DAYS: + time_str = applicant.get(day, '') + day_slots[day] = parse_time_slots(time_str) + + analysis = analyze_applicant_availability(timezone, day_slots) + + availability_results.append({ + 'discord_id': discord_id, + 'timezone': timezone, + 'utc_offset': analysis['utc_offset'], + 'available_blocks': analysis['available_blocks'], + 'block_counts': analysis['block_counts'], + 'total_unique_utc_hours': analysis['total_unique_utc_hours'] + }) + + with open('availability_analysis.json', 'w') as f: + json.dump(availability_results, f, indent=2) + + block_distribution = defaultdict(int) + for result in availability_results: + for block in result['available_blocks']: + block_distribution[block] += 1 + + print(f"\n=== AVAILABILITY ANALYSIS COMPLETE ===") + print(f"Analyzed: {len(availability_results)} applicants") + print(f"\nBlock Distribution (applicants available in each block):") + for block in ['mornings', 'afternoons', 'evenings', 'nights']: + print(f" {block.capitalize()}: {block_distribution[block]}") + + no_blocks = sum(1 for r in availability_results if not r['available_blocks']) + print(f"\nApplicants with no clear block availability: {no_blocks}") + + print(f"\nResults saved to availability_analysis.json") + +if __name__ == "__main__": + main() diff --git a/python/cohort/evaluate_technical_proficiency.py b/python/cohort/evaluate_technical_proficiency.py new file mode 100644 index 0000000..56c2e6c --- /dev/null +++ b/python/cohort/evaluate_technical_proficiency.py @@ -0,0 +1,238 @@ +import json +import re +import time +import urllib.request +import urllib.error +from typing import Optional + +# GitHub API (no auth needed for public repos, but rate limited) +GITHUB_API = "https://api.github.com" + +def extract_github_info(url: str) -> tuple[Optional[str], Optional[str]]: + """Extract owner and repo from GitHub URL.""" + # Handle various GitHub URL formats + patterns = [ + r'github\.com/([^/]+)/([^/\s?#]+)', # github.com/owner/repo + r'github\.com/([^/\s?#]+)/?$', # github.com/owner (profile) + ] + + for pattern in patterns: + match = re.search(pattern, url) + if match: + groups = match.groups() + if len(groups) == 2: + return groups[0], groups[1].rstrip('.git') + elif len(groups) == 1: + return groups[0], None + return None, None + +def fetch_github_user(username: str) -> Optional[dict]: + """Fetch GitHub user profile.""" + url = f"{GITHUB_API}/users/{username}" + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github.v3+json") + req.add_header("User-Agent", "Cohort-Evaluator") + + try: + response = urllib.request.urlopen(req, timeout=10) + return json.loads(response.read().decode()) + except Exception as e: + return None + +def fetch_github_repos(username: str) -> list: + """Fetch user's public repos.""" + url = f"{GITHUB_API}/users/{username}/repos?per_page=100&sort=updated" + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github.v3+json") + req.add_header("User-Agent", "Cohort-Evaluator") + + try: + response = urllib.request.urlopen(req, timeout=10) + return json.loads(response.read().decode()) + except Exception as e: + return [] + +def fetch_repo_languages(owner: str, repo: str) -> dict: + """Fetch languages used in a repo.""" + url = f"{GITHUB_API}/repos/{owner}/{repo}/languages" + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github.v3+json") + req.add_header("User-Agent", "Cohort-Evaluator") + + try: + response = urllib.request.urlopen(req, timeout=10) + return json.loads(response.read().decode()) + except Exception as e: + return {} + +def analyze_proficiency_text(text: str) -> tuple[str, list[str]]: + """Analyze self-described proficiency text.""" + text_lower = text.lower() + + # Extract languages/technologies mentioned + tech_patterns = [ + r'\b(python|java|javascript|typescript|c\+\+|c#|ruby|go|rust|swift|kotlin|php|perl|scala|r)\b', + r'\b(react|angular|vue|node|express|django|flask|spring|rails|laravel)\b', + r'\b(html|css|sass|scss|tailwind|bootstrap)\b', + r'\b(sql|mysql|postgresql|mongodb|redis|firebase)\b', + r'\b(docker|kubernetes|aws|azure|gcp|git)\b', + r'\b(machine learning|ml|ai|data science|tensorflow|pytorch)\b', + ] + + technologies = set() + for pattern in tech_patterns: + matches = re.findall(pattern, text_lower) + technologies.update(matches) + + # Determine level from keywords + beginner_keywords = ['beginner', 'learning', 'new to', 'just started', 'basic', 'novice', 'early'] + intermediate_keywords = ['intermediate', 'comfortable', 'familiar', 'some experience', 'worked with'] + advanced_keywords = ['advanced', 'expert', 'senior', 'professional', 'years of experience', 'proficient', 'strong'] + + level = 'intermediate' # default + + if any(kw in text_lower for kw in advanced_keywords): + level = 'advanced' + elif any(kw in text_lower for kw in beginner_keywords): + level = 'beginner' + elif any(kw in text_lower for kw in intermediate_keywords): + level = 'intermediate' + + return level, list(technologies) + +def evaluate_applicant(applicant: dict, index: int, total: int) -> dict: + """Evaluate a single applicant's technical proficiency.""" + discord_id = applicant['discord_id'] + project_url = applicant['project_url'] + proficiency_self = applicant['proficiency_self'] + project_reason = applicant['project_reason'] + + print(f"[{index+1}/{total}] Evaluating {discord_id}...") + + result = { + 'discord_id': discord_id, + 'github_username': None, + 'github_repos_count': 0, + 'github_followers': 0, + 'languages_from_github': [], + 'languages_from_text': [], + 'self_described_level': None, + 'final_proficiency': 'intermediate', # default + 'tech_stack': [], + 'notes': [] + } + + # Analyze self-description + text_level, text_techs = analyze_proficiency_text(proficiency_self + " " + project_reason) + result['self_described_level'] = text_level + result['languages_from_text'] = text_techs + + # Fetch GitHub data if URL provided + if project_url and 'github.com' in project_url: + owner, repo = extract_github_info(project_url) + + if owner: + result['github_username'] = owner + + # Fetch user profile + user_data = fetch_github_user(owner) + if user_data: + result['github_repos_count'] = user_data.get('public_repos', 0) + result['github_followers'] = user_data.get('followers', 0) + + # Fetch repos to get languages + repos = fetch_github_repos(owner) + all_languages = set() + for r in repos[:10]: # Check top 10 repos + if r.get('language'): + all_languages.add(r['language'].lower()) + result['languages_from_github'] = list(all_languages) + + # If specific repo provided, get its languages + if repo: + repo_langs = fetch_repo_languages(owner, repo) + for lang in repo_langs.keys(): + all_languages.add(lang.lower()) + result['languages_from_github'] = list(all_languages) + + time.sleep(0.5) # Rate limiting + + # Combine tech stack + all_tech = set(result['languages_from_github']) | set(result['languages_from_text']) + result['tech_stack'] = sorted(list(all_tech)) + + # Determine final proficiency + # Factors: self-description, GitHub activity, tech diversity + github_score = 0 + if result['github_repos_count'] >= 20: + github_score += 2 + elif result['github_repos_count'] >= 10: + github_score += 1 + + if result['github_followers'] >= 50: + github_score += 2 + elif result['github_followers'] >= 10: + github_score += 1 + + tech_count = len(result['tech_stack']) + if tech_count >= 6: + github_score += 2 + elif tech_count >= 3: + github_score += 1 + + # Map self-described level to score + level_scores = {'beginner': 0, 'intermediate': 2, 'advanced': 4} + self_score = level_scores.get(text_level, 2) + + # Combined score + total_score = github_score + self_score + + if total_score >= 7: + result['final_proficiency'] = 'advanced' + elif total_score >= 3: + result['final_proficiency'] = 'intermediate' + else: + result['final_proficiency'] = 'beginner' + + # Add notes + if not project_url or 'github.com' not in project_url: + result['notes'].append('No GitHub URL provided') + if result['github_repos_count'] == 0 and result['github_username']: + result['notes'].append('GitHub profile has no public repos') + + return result + +def main(): + # Load applicants + with open('applicants_to_evaluate.json', 'r') as f: + applicants = json.load(f) + + print(f"Evaluating {len(applicants)} applicants...\n") + + evaluations = [] + for i, applicant in enumerate(applicants): + result = evaluate_applicant(applicant, i, len(applicants)) + evaluations.append(result) + + # Progress update every 10 + if (i + 1) % 10 == 0: + print(f" Progress: {i+1}/{len(applicants)} complete") + + # Save results + with open('proficiency_evaluations.json', 'w') as f: + json.dump(evaluations, f, indent=2) + + # Summary + beginner = sum(1 for e in evaluations if e['final_proficiency'] == 'beginner') + intermediate = sum(1 for e in evaluations if e['final_proficiency'] == 'intermediate') + advanced = sum(1 for e in evaluations if e['final_proficiency'] == 'advanced') + + print(f"\n=== EVALUATION COMPLETE ===") + print(f"Beginner: {beginner}") + print(f"Intermediate: {intermediate}") + print(f"Advanced: {advanced}") + print(f"Total: {len(evaluations)}") + print(f"\nResults saved to proficiency_evaluations.json") + +if __name__ == "__main__": + main() diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..8a9a05b --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,74 @@ +[project] +name = "ephemere" +version = "1.0.0" +description = "Collection of ephemeral scripts" +authors = [ + { name = "Naomi Carrigan", email = "nhcarrigan@gmail.com" } +] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[project.optional-dependencies] +dev = [ + "ruff==0.14.14" +] + +[tool.ruff] +target-version = "py310" +line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # pyflakes + "F", + # isort + "I", + # pydocstyle + "D", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-comprehensions + "C4", + # flake8-datetimez + "DTZ", + # flake8-implicit-str-concat + "ISC", + # flake8-logging-format + "G", + # flake8-print + "T20", + # flake8-pytest-style + "PT", + # flake8-quotes + "Q", + # flake8-simplify + "SIM", + # flake8-tidy-imports + "TID", + # pylint + "PL", +] +ignore = [ + # Missing docstrings + "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", + # Let's not require module docstrings for scripts + "D100", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +known-first-party = ["py"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" \ No newline at end of file diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..77a3b8d --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,2 @@ +# Development dependencies +ruff==0.14.14 \ No newline at end of file diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..f6a9b45 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,43 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "ephemere" +version = "1.0.0" +source = { virtual = "." } + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }] +provides-extras = ["dev"] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..24b6948 --- /dev/null +++ b/run.sh @@ -0,0 +1,194 @@ +#!/bin/bash + +# Colors and formatting +PINK='\033[38;5;213m' +BLUE='\033[38;5;117m' +GREEN='\033[38;5;156m' +YELLOW='\033[38;5;229m' +CYAN='\033[38;5;123m' +WHITE='\033[1;37m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +# Box drawing characters +TOP_LEFT='╭' +TOP_RIGHT='╮' +BOTTOM_LEFT='╰' +BOTTOM_RIGHT='╯' +HORIZONTAL='─' +VERTICAL='│' +ARROW='❯' +SPARKLE='✨' +STAR='★' + +# Clear screen and show header +clear +echo -e "${PINK}${BOLD} ${TOP_LEFT}────────────────────────────────────${TOP_RIGHT}" +echo -e " ${VERTICAL} ${SPARKLE} ${WHITE}Ephemere Script Runner${PINK} ${SPARKLE} ${VERTICAL}" +echo -e " ${BOTTOM_LEFT}────────────────────────────────────${BOTTOM_RIGHT}${RESET}" + +# Function to display a menu and get selection +select_option() { + local prompt="$1" + shift + local options=("$@") + local selected=0 + local key + + # Hide cursor + tput civis + + # Trap to restore cursor on exit + trap 'tput cnorm' EXIT + + while true; do + # Clear previous menu (move up and clear lines) + if [ -n "$menu_drawn" ]; then + for ((i=0; i<=${#options[@]}+1; i++)); do + tput cuu1 + tput el + done + fi + menu_drawn=1 + + # Print prompt + echo -e "${CYAN}${BOLD} $prompt${RESET}" + echo "" + + # Print options + for i in "${!options[@]}"; do + if [ $i -eq $selected ]; then + echo -e " ${PINK}${BOLD}$ARROW ${WHITE}${options[$i]}${RESET}" + else + echo -e " ${DIM}${options[$i]}${RESET}" + fi + done + + # Read a single keypress + read -rsn1 key + + # Handle arrow keys (they come as escape sequences) + if [[ $key == $'\x1b' ]]; then + read -rsn2 key + case $key in + '[A') # Up arrow + ((selected--)) + [ $selected -lt 0 ] && selected=$((${#options[@]}-1)) + ;; + '[B') # Down arrow + ((selected++)) + [ $selected -ge ${#options[@]} ] && selected=0 + ;; + esac + elif [[ $key == "" ]]; then + # Enter pressed + tput cnorm # Show cursor + unset menu_drawn + return $selected + elif [[ $key == "q" || $key == "Q" ]]; then + tput cnorm + echo -e "\n${YELLOW} Bye bye! $SPARKLE${RESET}\n" + exit 0 + fi + done +} + +# Step 1: Select Language +echo "" +languages=("TypeScript" "Python") +select_option "Select a language:" "${languages[@]}" +lang_index=$? +language="${languages[$lang_index]}" + +echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$language${RESET}\n" + +# Step 2: Get categories based on language +if [ "$language" == "TypeScript" ]; then + script_dir="typescript/src" + 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 + 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 +fi + +if [ ${#categories[@]} -eq 0 ]; then + echo -e " ${YELLOW}No script categories found!${RESET}\n" + exit 1 +fi + +select_option "Select a category:" "${categories[@]}" +cat_index=$? +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 + search_dir="$script_dir/$category" + mapfile -t scripts < <(find "$search_dir" -name "*.ts" -exec basename {} \; | sort) +else + search_dir="$script_dir/$category" + mapfile -t scripts < <(find "$search_dir" -name "*.py" ! -name "__init__.py" -exec basename {} \; | sort) +fi + +if [ ${#scripts[@]} -eq 0 ]; then + echo -e " ${YELLOW}No scripts found in this category!${RESET}\n" + exit 1 +fi + +select_option "Select a script:" "${scripts[@]}" +script_index=$? +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" +elif [ "$language" == "TypeScript" ]; then + script_path="src/$category/$script" +else + script_path="$category/$script" +fi + +# Show what we're about to run +echo -e "${BLUE}${BOLD} ${TOP_LEFT}───────────────────────${TOP_RIGHT}" +echo -e " ${VERTICAL} ${WHITE}Running script...${BLUE} ${VERTICAL}" +echo -e " ${BOTTOM_LEFT}───────────────────────${BOTTOM_RIGHT}${RESET}" +echo "" +echo -e " ${DIM}Language:${RESET} $language" +echo -e " ${DIM}Category:${RESET} $category" +echo -e " ${DIM}Script:${RESET} $script" +echo "" + +# Run the script with 1Password env injection +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" +else + cd python + 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" +fi + +exit_code=$? + +echo "" +if [ $exit_code -eq 0 ]; then + echo -e " ${GREEN}${BOLD}$SPARKLE Script completed successfully! $SPARKLE${RESET}\n" +else + echo -e " ${YELLOW}${BOLD}Script exited with code $exit_code${RESET}\n" +fi diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 0000000..9f9adf7 --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,19 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +*.tsbuildinfo + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/typescript/Makefile b/typescript/Makefile new file mode 100644 index 0000000..d514abf --- /dev/null +++ b/typescript/Makefile @@ -0,0 +1,27 @@ +.PHONY: help install build lint test clean + +help: + @echo "TypeScript project commands:" + @echo " make install - Install dependencies" + @echo " make build - Build TypeScript (type check)" + @echo " make lint - Run ESLint" + @echo " make test - Run tests" + @echo " make clean - Clean build artifacts" + +install: + pnpm install --frozen-lockfile + +build: + pnpm exec tsc --noEmit + +lint: + pnpm exec eslint src --max-warnings 0 + +test: + @echo "No tests configured yet" + @exit 0 + +clean: + rm -rf node_modules + rm -rf dist + rm -f *.tsbuildinfo \ No newline at end of file diff --git a/eslint.config.js b/typescript/eslint.config.js similarity index 100% rename from eslint.config.js rename to typescript/eslint.config.js diff --git a/package.json b/typescript/package.json similarity index 76% rename from package.json rename to typescript/package.json index 4156f61..0f861b8 100644 --- a/package.json +++ b/typescript/package.json @@ -5,10 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "build": "tsc --noEmit", - "lint": "eslint src --max-warnings 0", - "start": "op run --env-file=prod.env --no-masking -- tsx", - "test": "echo \"Error: no test specified\" && exit 0" + "start": "op run --env-file=prod.env --no-masking -- tsx" }, "keywords": [], "author": "", diff --git a/pnpm-lock.yaml b/typescript/pnpm-lock.yaml similarity index 100% rename from pnpm-lock.yaml rename to typescript/pnpm-lock.yaml diff --git a/src/crowdin/clearHiddenTranslations.ts b/typescript/src/crowdin/clearHiddenTranslations.ts similarity index 100% rename from src/crowdin/clearHiddenTranslations.ts rename to typescript/src/crowdin/clearHiddenTranslations.ts diff --git a/src/crowdin/interfaces/file.ts b/typescript/src/crowdin/interfaces/file.ts similarity index 100% rename from src/crowdin/interfaces/file.ts rename to typescript/src/crowdin/interfaces/file.ts diff --git a/src/crowdin/interfaces/preTranslation.ts b/typescript/src/crowdin/interfaces/preTranslation.ts similarity index 100% rename from src/crowdin/interfaces/preTranslation.ts rename to typescript/src/crowdin/interfaces/preTranslation.ts diff --git a/src/crowdin/interfaces/project.ts b/typescript/src/crowdin/interfaces/project.ts similarity index 100% rename from src/crowdin/interfaces/project.ts rename to typescript/src/crowdin/interfaces/project.ts diff --git a/src/crowdin/interfaces/string.ts b/typescript/src/crowdin/interfaces/string.ts similarity index 100% rename from src/crowdin/interfaces/string.ts rename to typescript/src/crowdin/interfaces/string.ts diff --git a/src/crowdin/reapplyTranslations.ts b/typescript/src/crowdin/reapplyTranslations.ts similarity index 100% rename from src/crowdin/reapplyTranslations.ts rename to typescript/src/crowdin/reapplyTranslations.ts diff --git a/src/crowdin/utils/getFiles.ts b/typescript/src/crowdin/utils/getFiles.ts similarity index 100% rename from src/crowdin/utils/getFiles.ts rename to typescript/src/crowdin/utils/getFiles.ts diff --git a/src/crowdin/utils/getLanguages.ts b/typescript/src/crowdin/utils/getLanguages.ts similarity index 100% rename from src/crowdin/utils/getLanguages.ts rename to typescript/src/crowdin/utils/getLanguages.ts diff --git a/src/crowdin/utils/getStrings.ts b/typescript/src/crowdin/utils/getStrings.ts similarity index 100% rename from src/crowdin/utils/getStrings.ts rename to typescript/src/crowdin/utils/getStrings.ts diff --git a/src/crowdin/writeData.ts b/typescript/src/crowdin/writeData.ts similarity index 100% rename from src/crowdin/writeData.ts rename to typescript/src/crowdin/writeData.ts diff --git a/src/discord/cycThreads.ts b/typescript/src/discord/cycThreads.ts similarity index 100% rename from src/discord/cycThreads.ts rename to typescript/src/discord/cycThreads.ts diff --git a/src/discord/guildCount.ts b/typescript/src/discord/guildCount.ts similarity index 100% rename from src/discord/guildCount.ts rename to typescript/src/discord/guildCount.ts diff --git a/src/discourse/bulkUpdateCategories.ts b/typescript/src/discourse/bulkUpdateCategories.ts similarity index 100% rename from src/discourse/bulkUpdateCategories.ts rename to typescript/src/discourse/bulkUpdateCategories.ts diff --git a/src/discourse/closeOldTopics.ts b/typescript/src/discourse/closeOldTopics.ts similarity index 100% rename from src/discourse/closeOldTopics.ts rename to typescript/src/discourse/closeOldTopics.ts diff --git a/src/gitea/deleteFromAllRepos.ts b/typescript/src/gitea/deleteFromAllRepos.ts similarity index 100% rename from src/gitea/deleteFromAllRepos.ts rename to typescript/src/gitea/deleteFromAllRepos.ts diff --git a/src/gitea/uploadToAllRepos.ts b/typescript/src/gitea/uploadToAllRepos.ts similarity index 100% rename from src/gitea/uploadToAllRepos.ts rename to typescript/src/gitea/uploadToAllRepos.ts diff --git a/src/gitea/uploadToReposConditionally.ts b/typescript/src/gitea/uploadToReposConditionally.ts similarity index 100% rename from src/gitea/uploadToReposConditionally.ts rename to typescript/src/gitea/uploadToReposConditionally.ts diff --git a/src/github/auditNpmPackages.ts b/typescript/src/github/auditNpmPackages.ts similarity index 100% rename from src/github/auditNpmPackages.ts rename to typescript/src/github/auditNpmPackages.ts diff --git a/src/github/postUserStories.ts b/typescript/src/github/postUserStories.ts similarity index 100% rename from src/github/postUserStories.ts rename to typescript/src/github/postUserStories.ts diff --git a/src/index.ts b/typescript/src/index.ts similarity index 100% rename from src/index.ts rename to typescript/src/index.ts diff --git a/src/interfaces/discourse.ts b/typescript/src/interfaces/discourse.ts similarity index 100% rename from src/interfaces/discourse.ts rename to typescript/src/interfaces/discourse.ts diff --git a/src/interfaces/dojo.ts b/typescript/src/interfaces/dojo.ts similarity index 100% rename from src/interfaces/dojo.ts rename to typescript/src/interfaces/dojo.ts diff --git a/src/interfaces/gitea.ts b/typescript/src/interfaces/gitea.ts similarity index 100% rename from src/interfaces/gitea.ts rename to typescript/src/interfaces/gitea.ts diff --git a/src/music/id3v2.ts b/typescript/src/music/id3v2.ts similarity index 100% rename from src/music/id3v2.ts rename to typescript/src/music/id3v2.ts diff --git a/src/s3/bulkUpload.ts b/typescript/src/s3/bulkUpload.ts similarity index 100% rename from src/s3/bulkUpload.ts rename to typescript/src/s3/bulkUpload.ts diff --git a/src/s3/correctContentType.ts b/typescript/src/s3/correctContentType.ts similarity index 100% rename from src/s3/correctContentType.ts rename to typescript/src/s3/correctContentType.ts diff --git a/src/s3/upload.ts b/typescript/src/s3/upload.ts similarity index 100% rename from src/s3/upload.ts rename to typescript/src/s3/upload.ts diff --git a/src/security/generateReport.ts b/typescript/src/security/generateReport.ts similarity index 100% rename from src/security/generateReport.ts rename to typescript/src/security/generateReport.ts diff --git a/src/utils/backoffAndRetry.ts b/typescript/src/utils/backoffAndRetry.ts similarity index 100% rename from src/utils/backoffAndRetry.ts rename to typescript/src/utils/backoffAndRetry.ts diff --git a/typescript/src/utils/interactive-runner.ts b/typescript/src/utils/interactive-runner.ts new file mode 100644 index 0000000..683467a --- /dev/null +++ b/typescript/src/utils/interactive-runner.ts @@ -0,0 +1,165 @@ +import { select } from "@inquirer/prompts"; +import { execSync } from "child_process"; +import { readdirSync, statSync, existsSync } from "fs"; +import { join, relative } from "path"; + +interface ScriptOption { + name: string; + value: string; + description?: string; +} + +const getTypeScriptCategories = (): string[] => { + const srcPath = join(__dirname, ".."); + const entries = readdirSync(srcPath); + + return entries + .filter((entry) => { + const fullPath = join(srcPath, entry); + return statSync(fullPath).isDirectory() && entry !== "utils" && entry !== "interfaces"; + }) + .sort(); +}; + +const getTypeScriptScripts = (category: string): ScriptOption[] => { + const categoryPath = join(__dirname, "..", category); + const scripts: ScriptOption[] = []; + + const walkDirectory = (dir: string) => { + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + walkDirectory(fullPath); + } else if (entry.endsWith(".ts") && entry !== "index.ts") { + const relativePath = relative(join(__dirname, ".."), fullPath); + scripts.push({ + name: entry.replace(".ts", ""), + value: relativePath, + description: relativePath, + }); + } + } + }; + + walkDirectory(categoryPath); + return scripts.sort((a, b) => a.name.localeCompare(b.name)); +}; + +const getPythonCategories = (): string[] => { + const pythonPath = join(__dirname, "../../../../python"); + const entries = readdirSync(pythonPath); + + const categories = entries + .filter((entry) => { + const fullPath = join(pythonPath, entry); + return statSync(fullPath).isDirectory() && + !entry.startsWith(".") && + entry !== "__pycache__"; + }) + .sort(); + + // Also check for scripts in the root + const hasRootScripts = entries.some(entry => entry.endsWith(".py")); + if (hasRootScripts) { + categories.unshift("(root)"); + } + + return categories; +}; + +const getPythonScripts = (category: string): ScriptOption[] => { + const pythonPath = join(__dirname, "../../../../python"); + const searchPath = category === "(root)" ? pythonPath : join(pythonPath, category); + + const scripts: ScriptOption[] = []; + const entries = readdirSync(searchPath); + + for (const entry of entries) { + if (entry.endsWith(".py") && !entry.startsWith("__")) { + const relativePath = category === "(root)" ? entry : join(category, entry); + scripts.push({ + name: entry.replace(".py", ""), + value: relativePath, + description: relativePath, + }); + } + } + + return scripts.sort((a, b) => a.name.localeCompare(b.name)); +}; + +const main = async () => { + console.log("🌸 Welcome to Ephemere Script Runner! 💖\n"); + + // Select language + const language = await select({ + message: "Which language would you like to run?", + choices: [ + { name: "TypeScript", value: "typescript", description: "Run a TypeScript script" }, + { name: "Python", value: "python", description: "Run a Python script" }, + ], + }); + + // Get categories based on language + const categories = language === "typescript" + ? getTypeScriptCategories() + : getPythonCategories(); + + if (categories.length === 0) { + console.error(`No categories found for ${language}!`); + process.exit(1); + } + + // Select category + const category = await select({ + message: "Which category?", + choices: categories.map(cat => ({ + name: cat === "(root)" ? "Root Directory" : cat.charAt(0).toUpperCase() + cat.slice(1), + value: cat, + })), + }); + + // Get scripts for selected category + const scripts = language === "typescript" + ? getTypeScriptScripts(category) + : getPythonScripts(category); + + if (scripts.length === 0) { + console.error(`No scripts found in ${category}!`); + process.exit(1); + } + + // Select script + const script = await select({ + message: "Which script would you like to run?", + choices: scripts, + }); + + // Build and execute the command + const prodEnvPath = join(__dirname, "../../../../../prod.env"); + let command: string; + + if (language === "typescript") { + command = `cd ${join(__dirname, "../../../")} && op run --env-file=${prodEnvPath} -- pnpm exec tsx src/${script}`; + } else { + command = `cd ${join(__dirname, "../../../../python")} && op run --env-file=${prodEnvPath} -- uv run python ${script}`; + } + + console.log(`\n✨ Running: ${script}\n`); + + try { + execSync(command, { + stdio: "inherit", + shell: true, + }); + } catch (error) { + console.error("\n❌ Script execution failed!"); + process.exit(1); + } +}; + +main().catch(console.error); \ No newline at end of file diff --git a/src/utils/mimeType.ts b/typescript/src/utils/mimeType.ts similarity index 100% rename from src/utils/mimeType.ts rename to typescript/src/utils/mimeType.ts diff --git a/src/utils/paginatedFetch.ts b/typescript/src/utils/paginatedFetch.ts similarity index 100% rename from src/utils/paginatedFetch.ts rename to typescript/src/utils/paginatedFetch.ts diff --git a/src/utils/serialiseJsonOrError.ts b/typescript/src/utils/serialiseJsonOrError.ts similarity index 100% rename from src/utils/serialiseJsonOrError.ts rename to typescript/src/utils/serialiseJsonOrError.ts diff --git a/src/utils/sleep.ts b/typescript/src/utils/sleep.ts similarity index 100% rename from src/utils/sleep.ts rename to typescript/src/utils/sleep.ts diff --git a/tsconfig.json b/typescript/tsconfig.json similarity index 100% rename from tsconfig.json rename to typescript/tsconfig.json -- 2.52.0 From 6184801fed0dc391e3a12f6a40eaaa725085838d Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Fri, 23 Jan 2026 18:26:39 -0800 Subject: [PATCH 2/5] feat: cohort scripts --- prod.env | 1 + python/cohort/assign_cohort_role.py | 86 ++++++++++ python/cohort/assign_team_roles.py | 104 ++++++++++++ python/cohort/generate_member_files.py | 216 +++++++++++++++++++++++++ python/cohort/generate_timeslots.py | 24 +++ python/cohort/send_team_messages.py | 197 ++++++++++++++++++++++ python/cohort/verify_discord.py | 108 +++++++++++++ 7 files changed, 736 insertions(+) create mode 100644 python/cohort/assign_cohort_role.py create mode 100644 python/cohort/assign_team_roles.py create mode 100644 python/cohort/generate_member_files.py create mode 100644 python/cohort/generate_timeslots.py create mode 100644 python/cohort/send_team_messages.py create mode 100644 python/cohort/verify_discord.py diff --git a/prod.env b/prod.env index 8ad11cb..c794742 100644 --- a/prod.env +++ b/prod.env @@ -10,6 +10,7 @@ GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token" DISCORD_TOKEN="op://Environment Variables - Development/Ephemere/Discord Token" DISCORD_CLIENT_ID="op://Private/Guild Counter/client id" DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret" +DISCORD_BOT_TOKEN="op://Private/Amari Bot/Token" # AWS AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID" diff --git a/python/cohort/assign_cohort_role.py b/python/cohort/assign_cohort_role.py new file mode 100644 index 0000000..e4bfce6 --- /dev/null +++ b/python/cohort/assign_cohort_role.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Assign the Cohort role to all 155 participants. +Respects Discord rate limits with proper backoff and retry logic. +""" + +import json +import os +import time +import requests + +BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" +COHORT_ROLE_ID = "1464314780935258112" + +BASE_URL = "https://discord.com/api/v10" +HEADERS = { + "Authorization": f"Bot {BOT_TOKEN}", + "Content-Length": "0" +} + +def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool: + url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}" + + for attempt in range(max_retries): + response = requests.put(url, headers=HEADERS) + + if response.status_code == 204: + return True + elif response.status_code == 429: + # Check headers first, fall back to JSON body + retry_after = response.headers.get("Retry-After") + if retry_after is None: + retry_after = response.headers.get("X-RateLimit-Reset-After") + if retry_after is None: + try: + retry_after = response.json().get("retry_after", 1) + except: + retry_after = 1 + retry_after = float(retry_after) + print(f" Rate limited! Waiting {retry_after:.2f}s before retry...") + time.sleep(retry_after) + else: + print(f" Error {response.status_code}: {response.text}") + backoff_time = (2 ** attempt) * 0.5 + print(f" Retrying in {backoff_time:.2f}s...") + time.sleep(backoff_time) + + return False + +def main(): + with open("team_assignments.json", "r") as f: + teams = json.load(f) + + all_users = [] + for team in teams: + all_users.extend(team["leaders"]) + all_users.extend(team["participants"]) + + unique_users = list(dict.fromkeys(all_users)) + + print(f"Assigning Cohort role to {len(unique_users)} users...") + print(f"Role ID: {COHORT_ROLE_ID}") + print("-" * 50) + + success_count = 0 + fail_count = 0 + + for i, user_id in enumerate(unique_users, 1): + print(f"[{i}/{len(unique_users)}] Assigning to {user_id}...", end=" ") + + if assign_role_with_retry(user_id, COHORT_ROLE_ID): + print("✓") + success_count += 1 + else: + print("✗ FAILED") + fail_count += 1 + + # Small delay between requests to be nice to the API + time.sleep(0.1) + + print("-" * 50) + print(f"Complete! Success: {success_count}, Failed: {fail_count}") + +if __name__ == "__main__": + main() diff --git a/python/cohort/assign_team_roles.py b/python/cohort/assign_team_roles.py new file mode 100644 index 0000000..2280d2f --- /dev/null +++ b/python/cohort/assign_team_roles.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Assign team-specific roles to all 155 participants. +Respects Discord rate limits with proper backoff and retry logic. +""" + +import json +import os +import time +import requests + +BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" + +BASE_URL = "https://discord.com/api/v10" +HEADERS = { + "Authorization": f"Bot {BOT_TOKEN}", + "Content-Length": "0" +} + +TEAM_ROLE_IDS = { + "Jade Jasmine": "1464314923780931677", + "Crimson Dahlia": "1464315093402784015", + "Rose Camellia": "1464315098452726106", + "Amber Wisteria": "1464315105264275600", + "Ivory Orchid": "1464315109873684593", + "Teal Iris": "1464315114378498152", + "Peach Gardenia": "1464315118904152107", + "Violet Carnation": "1464315124251754559", + "Azure Lotus": "1464315128437801177", + "Coral Sunflower": "1464315132896088168", + "Indigo Tulip": "1464315138428633241", + "Scarlet Hydrangea": "1464315142710890520", + "Mint Narcissus": "1464315149203804405", + "Sage Marigold": "1464315153599299803", +} + + +def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool: + url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}" + + for attempt in range(max_retries): + response = requests.put(url, headers=HEADERS) + + if response.status_code == 204: + return True + elif response.status_code == 429: + # Check headers first, fall back to JSON body + retry_after = response.headers.get("Retry-After") + if retry_after is None: + retry_after = response.headers.get("X-RateLimit-Reset-After") + if retry_after is None: + try: + retry_after = response.json().get("retry_after", 1) + except: + retry_after = 1 + retry_after = float(retry_after) + print(f" Rate limited! Waiting {retry_after:.2f}s before retry...") + time.sleep(retry_after) + else: + print(f" Error {response.status_code}: {response.text}") + backoff_time = (2 ** attempt) * 0.5 + print(f" Retrying in {backoff_time:.2f}s...") + time.sleep(backoff_time) + + return False + + +def main(): + with open("team_assignments.json", "r") as f: + teams = json.load(f) + + print(f"Assigning team roles to {len(teams)} teams...") + print("-" * 50) + + total_success = 0 + total_fail = 0 + + for team in teams: + team_name = team["name"] + role_id = TEAM_ROLE_IDS[team_name] + all_members = team["leaders"] + team["participants"] + + print(f"\n[{team_name}] Assigning role to {len(all_members)} members...") + + for user_id in all_members: + print(f" {user_id}...", end=" ") + + if assign_role_with_retry(user_id, role_id): + print("✓") + total_success += 1 + else: + print("✗ FAILED") + total_fail += 1 + + # Small delay between requests to be nice to the API + time.sleep(0.1) + + print("-" * 50) + print(f"Complete! Success: {total_success}, Failed: {total_fail}") + + +if __name__ == "__main__": + main() diff --git a/python/cohort/generate_member_files.py b/python/cohort/generate_member_files.py new file mode 100644 index 0000000..03042eb --- /dev/null +++ b/python/cohort/generate_member_files.py @@ -0,0 +1,216 @@ +import json + +BLOCK_EMOJIS = { + 'mornings': '🌅', + 'afternoons': '☀️', + 'evenings': '🌆', + 'nights': '🌙' +} + +def load_all_data(): + """Load all evaluation data files""" + with open('discord_verification.json', 'r') as f: + verification = json.load(f) + + with open('proficiency_evaluations.json', 'r') as f: + proficiency = json.load(f) + + with open('availability_analysis.json', 'r') as f: + availability = json.load(f) + + with open('leadership_candidates.json', 'r') as f: + candidates = json.load(f) + + with open('leadership_evaluations.json', 'r') as f: + leadership = json.load(f) + + return verification, proficiency, availability, candidates, leadership + +def build_lookup_dicts(verification, proficiency, availability, leadership): + """Build lookup dictionaries by discord_id""" + verified_usernames = {v[0]: v[1] for v in verification['verified']} + + prof_by_id = {p['discord_id']: p for p in proficiency} + + avail_by_id = {a['discord_id']: a for a in availability} + + lead_by_id = {l['discord_id']: l for l in leadership} + + return verified_usernames, prof_by_id, avail_by_id, lead_by_id + +def format_availability_blocks(blocks): + """Format availability blocks with emojis""" + if not blocks: + return "No consistent availability" + + formatted = [] + for block in ['mornings', 'afternoons', 'evenings', 'nights']: + if block in blocks: + formatted.append(f"{BLOCK_EMOJIS[block]} {block.capitalize()}") + return ", ".join(formatted) + +def format_tech_stack(tech_stack): + """Format tech stack list""" + if not tech_stack: + return "Not specified" + return ", ".join(sorted(tech_stack)) + +def generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, avail_by_id): + """Generate participants.md for non-leaders""" + lines = [ + "# Cohort Participants", + "", + f"**Total Participants**: {len(non_leader_ids)}", + "", + "---", + "" + ] + + beginner_count = 0 + intermediate_count = 0 + advanced_count = 0 + + for discord_id in sorted(non_leader_ids): + if discord_id not in verified_usernames: + continue + + username = verified_usernames.get(discord_id, "Unknown") + prof = prof_by_id.get(discord_id, {}) + avail = avail_by_id.get(discord_id, {}) + + proficiency = prof.get('final_proficiency', 'unknown') + tech_stack = prof.get('tech_stack', []) + blocks = avail.get('available_blocks', []) + notes = prof.get('notes', []) + + if proficiency == 'beginner': + beginner_count += 1 + elif proficiency == 'intermediate': + intermediate_count += 1 + elif proficiency == 'advanced': + advanced_count += 1 + + lines.append(f"## {discord_id}") + lines.append(f"**Username**: @{username}") + lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}") + lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}") + lines.append(f"**Availability**: {format_availability_blocks(blocks)}") + if notes: + lines.append(f"**Notes**: {', '.join(notes)}") + lines.append("") + + summary = [ + "# Cohort Participants", + "", + f"**Total Participants**: {len([id for id in non_leader_ids if id in verified_usernames])}", + "", + "### Proficiency Breakdown", + f"- Beginner: {beginner_count}", + f"- Intermediate: {intermediate_count}", + f"- Advanced: {advanced_count}", + "", + "---", + "" + ] + + return "\n".join(summary + lines[6:]) + +def leadership_fit_label(score): + """Convert leadership score to label""" + if score >= 6: + return "Excellent" + elif score >= 4: + return "Good" + elif score >= 2: + return "Adequate" + else: + return "Needs Review" + +def generate_leaders_md(leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id): + """Generate leaders.md for leadership candidates""" + verified_leaders = [id for id in leader_ids if id in verified_usernames] + + lines = [ + "# Cohort Leaders", + "", + f"**Total Leaders**: {len(verified_leaders)}", + "", + "---", + "" + ] + + sorted_leaders = sorted(verified_leaders, key=lambda x: lead_by_id.get(x, {}).get('leadership_score', 0), reverse=True) + + for discord_id in sorted_leaders: + username = verified_usernames.get(discord_id, "Unknown") + prof = prof_by_id.get(discord_id, {}) + avail = avail_by_id.get(discord_id, {}) + lead = lead_by_id.get(discord_id, {}) + + proficiency = prof.get('final_proficiency', 'unknown') + tech_stack = prof.get('tech_stack', []) + blocks = avail.get('available_blocks', []) + + leadership_score = lead.get('leadership_score', 0) + leadership_fit = lead.get('leadership_fit', 'unknown') + leadership_notes = lead.get('notes', []) + prof_notes = prof.get('notes', []) + + lines.append(f"## {discord_id}") + lines.append(f"**Username**: @{username}") + lines.append(f"**Leadership Fit**: {leadership_fit.capitalize()} (Score: {leadership_score})") + lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}") + lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}") + lines.append(f"**Availability**: {format_availability_blocks(blocks)}") + if leadership_notes: + lines.append(f"**Leadership Notes**: {', '.join(leadership_notes)}") + if prof_notes: + lines.append(f"**Technical Notes**: {', '.join(prof_notes)}") + lines.append("") + + excellent = sum(1 for id in verified_leaders if lead_by_id.get(id, {}).get('leadership_fit') == 'excellent') + good = sum(1 for id in verified_leaders if lead_by_id.get(id, {}).get('leadership_fit') == 'good') + adequate = sum(1 for id in verified_leaders if lead_by_id.get(id, {}).get('leadership_fit') == 'adequate') + + summary = [ + "# Cohort Leaders", + "", + f"**Total Leaders**: {len(verified_leaders)}", + "", + "### Leadership Fit Breakdown", + f"- Excellent: {excellent}", + f"- Good: {good}", + f"- Adequate: {adequate}", + "", + "---", + "" + ] + + return "\n".join(summary + lines[6:]) + +def main(): + verification, proficiency, availability, candidates, leadership = load_all_data() + + verified_usernames, prof_by_id, avail_by_id, lead_by_id = build_lookup_dicts( + verification, proficiency, availability, leadership + ) + + leader_ids = set(candidates['leaders']) + non_leader_ids = set(candidates['non_leaders']) + + verified_ids = set(verified_usernames.keys()) + leader_ids = leader_ids & verified_ids + non_leader_ids = non_leader_ids & verified_ids + + participants_md = generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, avail_by_id) + with open('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: + f.write(leaders_md) + print(f"Generated leaders.md with {len(leader_ids)} leaders") + +if __name__ == "__main__": + main() diff --git a/python/cohort/generate_timeslots.py b/python/cohort/generate_timeslots.py new file mode 100644 index 0000000..e34d4bc --- /dev/null +++ b/python/cohort/generate_timeslots.py @@ -0,0 +1,24 @@ +from datetime import datetime, timedelta +import json + +# Generate hourly time slots from Feb 1 to March 3, 2026 +# 24 hours a day, America/Los_Angeles timezone +start_date = datetime(2026, 2, 1, 0, 0) # Feb 1, 2026, midnight +end_date = datetime(2026, 3, 3, 23, 0) # March 3, 2026, 11pm + +times = [] +current = start_date +while current <= end_date: + # Format: YYYY-MM-DDTHH:MM + times.append(current.strftime("%Y-%m-%dT%H:%M")) + current += timedelta(hours=1) + +print(f"Generated {len(times)} time slots") +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: + json.dump(times, f) + +print("Saved to crabfit_timeslots.json") diff --git a/python/cohort/send_team_messages.py b/python/cohort/send_team_messages.py new file mode 100644 index 0000000..a5f2ee9 --- /dev/null +++ b/python/cohort/send_team_messages.py @@ -0,0 +1,197 @@ +import json +import os +import time +import requests + +# Amari's bot token +TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" + +# File to save message IDs +MESSAGE_IDS_FILE = "team_message_ids.json" + +# Team channel IDs and role IDs +TEAMS = { + "Jade Jasmine": {"channel_id": "1464316501573107886", "role_id": "1464314923780931677"}, + "Crimson Dahlia": {"channel_id": "1464316744909852682", "role_id": "1464315093402784015"}, + "Rose Camellia": {"channel_id": "1464316751268286611", "role_id": "1464315098452726106"}, + "Amber Wisteria": {"channel_id": "1464316761410113641", "role_id": "1464315105264275600"}, + "Ivory Orchid": {"channel_id": "1464316770889240730", "role_id": "1464315109873684593"}, + "Teal Iris": {"channel_id": "1464316776459407448", "role_id": "1464315114378498152"}, + "Peach Gardenia": {"channel_id": "1464316785040953543", "role_id": "1464315118904152107"}, + "Violet Carnation": {"channel_id": "1464316805261824032", "role_id": "1464315124251754559"}, + "Azure Lotus": {"channel_id": "1464316814455472139", "role_id": "1464315128437801177"}, + "Coral Sunflower": {"channel_id": "1464316819711066263", "role_id": "1464315132896088168"}, + "Indigo Tulip": {"channel_id": "1464316826384072925", "role_id": "1464315138428633241"}, + "Scarlet Hydrangea": {"channel_id": "1464316839306985506", "role_id": "1464315142710890520"}, + "Mint Narcissus": {"channel_id": "1464316844251807952", "role_id": "1464315149203804405"}, + "Sage Marigold": {"channel_id": "1464316850669093040", "role_id": "1464315153599299803"}, +} + +# Load team assignments and convert to dict by team name +with open("team_assignments.json", "r") 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", "r") as f: + applicants = json.load(f) + applicant_lookup = {str(a["discord_id"]): a for a in applicants} + +def extract_github_username(url): + """Extract GitHub username from various URL formats""" + if not url: + return "unknown" + + url = url.strip() + + # Handle GitLab special case (RashiqAzhan) + if "gitlab.com" in url: + # We know this is RashiqAzhan from earlier confirmation + return "RashiqAzhan" + + # Handle GitHub Pages URLs + if ".github.io" in url: + # Extract username from username.github.io format + parts = url.replace("https://", "").replace("http://", "").split(".") + if parts: + return parts[0] + + # Handle plain usernames (no URL) + if not url.startswith("http"): + return url + + # Handle standard GitHub URLs + if "github.com" in url: + # Remove protocol and github.com + path = url.replace("https://", "").replace("http://", "").replace("github.com/", "") + # Get just the username (first part of path) + username = path.split("/")[0] + return username + + return url + +def build_message(team_name, role_id, leader_ids, participant_ids): + """Build the welcome message for a team""" + lines = [ + f"# {team_name}", + "", + f"Welcome, <@&{role_id}>. This is your private team channel — a space for you to collaborate, support one another, and build something meaningful together.", + "", + "## Roster", + "", + "**Leadership**", + ] + + for discord_id in leader_ids: + applicant = applicant_lookup.get(str(discord_id), {}) + project_url = applicant.get("project_url", "") + github_username = extract_github_username(project_url) + lines.append(f"- <@{discord_id}>: https://github.com/{github_username}") + + lines.append("") + lines.append("**Participants**") + + for discord_id in participant_ids: + applicant = applicant_lookup.get(str(discord_id), {}) + project_url = applicant.get("project_url", "") + github_username = extract_github_username(project_url) + lines.append(f"- <@{discord_id}>: https://github.com/{github_username}") + + lines.append("") + lines.append("## Project Info") + lines.append("") + lines.append("Coming soon. 💜") + + return "\n".join(lines) + +def send_message(channel_id, content): + """Send a message to a channel""" + url = f"https://discord.com/api/v10/channels/{channel_id}/messages" + headers = { + "Authorization": f"Bot {TOKEN}", + "Content-Type": "application/json" + } + data = {"content": content} + + response = requests.post(url, headers=headers, json=data) + + if response.status_code == 429: + retry_after = float(response.headers.get("Retry-After", response.headers.get("X-RateLimit-Reset-After", 1))) + print(f"Rate limited, waiting {retry_after}s...") + time.sleep(retry_after) + return send_message(channel_id, content) + + if response.status_code == 200: + return response.json() + else: + print(f"Error sending message: {response.status_code} - {response.text}") + return None + +def pin_message(channel_id, message_id): + """Pin a message in a channel""" + url = f"https://discord.com/api/v10/channels/{channel_id}/pins/{message_id}" + headers = { + "Authorization": f"Bot {TOKEN}", + } + + response = requests.put(url, headers=headers) + + if response.status_code == 429: + retry_after = float(response.headers.get("Retry-After", response.headers.get("X-RateLimit-Reset-After", 1))) + print(f"Rate limited, waiting {retry_after}s...") + time.sleep(retry_after) + return pin_message(channel_id, message_id) + + return response.status_code == 204 + +def main(): + message_ids = {} + + for team_name, team_info in TEAMS.items(): + channel_id = team_info["channel_id"] + role_id = team_info["role_id"] + + # Get team members from team_data + team = team_data.get(team_name, {"leaders": [], "participants": []}) + leaders = team.get("leaders", []) + participants = team.get("participants", []) + + # Build the message + message_content = build_message(team_name, role_id, leaders, participants) + + print(f"Sending message to {team_name}...") + + # Send the message + result = send_message(channel_id, message_content) + + if result: + message_id = result["id"] + message_ids[team_name] = { + "channel_id": channel_id, + "message_id": message_id, + "role_id": role_id + } + print(f" Message sent! ID: {message_id}") + + # Pin the message + print(f" Pinning message...") + if pin_message(channel_id, message_id): + print(f" Pinned!") + else: + print(f" Failed to pin") + else: + print(f" Failed to send message") + + # Small delay between teams + time.sleep(0.2) + + # Save message IDs to file + with open(MESSAGE_IDS_FILE, "w") as f: + json.dump(message_ids, f, indent=2) + + print(f"\nDone! Message IDs saved to {MESSAGE_IDS_FILE}") + print(f"Successfully sent and pinned messages for {len(message_ids)} teams") + +if __name__ == "__main__": + main() diff --git a/python/cohort/verify_discord.py b/python/cohort/verify_discord.py new file mode 100644 index 0000000..15812b0 --- /dev/null +++ b/python/cohort/verify_discord.py @@ -0,0 +1,108 @@ +import json +import os +import time +import urllib.request +import urllib.error + +# Configuration +BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] +GUILD_ID = "692816967895220344" +BASE_URL = "https://discord.com/api/v10" + +# Read Discord IDs from table.md +with open("table.md", "r") as f: + content = f.read() + +lines = content.strip().split("\n") + +# Find the table header line (starts with |) +header_line = None +header_idx = 0 +for i, line in enumerate(lines): + if line.startswith("| Discord"): + header_line = line + header_idx = i + break + +if not header_line: + print("Could not find table header!") + exit(1) + +headers = [h.strip() for h in header_line.split("|")[1:-1]] + +discord_idx = 0 # Discord ID is the first column + +discord_ids = [] +for line in lines[header_idx + 2:]: # Skip header and separator + if not line.startswith("|"): + continue + cols = [c.strip() for c in line.split("|")[1:-1]] + if len(cols) > discord_idx: + discord_id = cols[discord_idx].strip() + if discord_id and discord_id.isdigit(): + discord_ids.append(discord_id) + +print(f"Found {len(discord_ids)} Discord IDs to verify") + +# Verify each ID against the guild +verified = [] +missing = [] +errors = [] + +for i, discord_id in enumerate(discord_ids): + url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{discord_id}" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bot {BOT_TOKEN}") + + try: + response = urllib.request.urlopen(req) + data = json.loads(response.read().decode()) + username = data.get("user", {}).get("username", "Unknown") + verified.append((discord_id, username)) + print(f"[{i+1}/{len(discord_ids)}] ✓ {discord_id} - {username}") + except urllib.error.HTTPError as e: + if e.code == 404: + missing.append(discord_id) + print(f"[{i+1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER") + elif e.code == 429: + # Rate limited - wait and retry + retry_after = json.loads(e.read().decode()).get("retry_after", 1) + print(f"[{i+1}/{len(discord_ids)}] Rate limited, waiting {retry_after}s...") + time.sleep(retry_after + 0.5) + # Retry + try: + req2 = urllib.request.Request(url) + req2.add_header("Authorization", f"Bot {BOT_TOKEN}") + response = urllib.request.urlopen(req2) + data = json.loads(response.read().decode()) + username = data.get("user", {}).get("username", "Unknown") + verified.append((discord_id, username)) + print(f"[{i+1}/{len(discord_ids)}] ✓ {discord_id} - {username} (after retry)") + except urllib.error.HTTPError as e2: + if e2.code == 404: + missing.append(discord_id) + print(f"[{i+1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER (after retry)") + else: + errors.append((discord_id, f"HTTP {e2.code}")) + print(f"[{i+1}/{len(discord_ids)}] ? {discord_id} - Error {e2.code}") + else: + errors.append((discord_id, f"HTTP {e.code}")) + print(f"[{i+1}/{len(discord_ids)}] ? {discord_id} - Error {e.code}") + + # Small delay to avoid rate limits + time.sleep(0.1) + +print(f"\n=== SUMMARY ===") +print(f"Verified: {len(verified)}") +print(f"Missing: {len(missing)}") +print(f"Errors: {len(errors)}") + +# Save results +with open("discord_verification.json", "w") as f: + json.dump({ + "verified": verified, + "missing": missing, + "errors": errors + }, f, indent=2) + +print("\nResults saved to discord_verification.json") -- 2.52.0 From f67026cfbbc48109fe5f8b0c3e959bf033f87366 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Fri, 23 Jan 2026 18:59:21 -0800 Subject: [PATCH 3/5] chore: deps --- .gitea/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e6355cb..454d4f8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: cp typescript/package-lock.json . 2>/dev/null || true cp typescript/pnpm-lock.yaml . 2>/dev/null || true - uses: naomi-lgbt/dependency-pin-check@main + with: + language: typescript + dev-dependencies: true + peer-dependencies: true + optional-dependencies: true dependency-pin-check-python: runs-on: ubuntu-latest @@ -27,6 +32,9 @@ jobs: cp python/requirements.txt . cp python/pyproject.toml . - uses: naomi-lgbt/dependency-pin-check@main + with: + language: python + dev-dependencies: true typescript: needs: dependency-pin-check-typescript -- 2.52.0 From 611ca895f80ef24a58589c6ac6cb3a74022d8f06 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 23 Jan 2026 19:17:47 -0800 Subject: [PATCH 4/5] fix: add package.json workaround for Python dependency check --- .gitea/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 454d4f8..541e56e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,6 +31,8 @@ jobs: run: | cp python/requirements.txt . cp python/pyproject.toml . + # Create empty package.json to prevent ENOENT error + echo '{}' > package.json - uses: naomi-lgbt/dependency-pin-check@main with: language: python -- 2.52.0 From f8598d6ddf2f8e51fd0bbc7c792a5029018025c9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 23 Jan 2026 19:46:23 -0800 Subject: [PATCH 5/5] fix: resolve lint issues for Python and TypeScript - Update pyproject.toml to ignore T201 (print statements) and other rules - Fix quote styles, bare except, set comprehensions in Python scripts - Rename interactive-runner.ts to interactiveRunner.ts (camelCase) - Refactor TypeScript to use import.meta.url instead of __dirname - Add proper JSDoc headers and rename abbreviated variables --- python/cohort/analyse_availability.py | 107 +++++---- python/cohort/assign_cohort_role.py | 18 +- python/cohort/assign_team_roles.py | 15 +- .../cohort/evaluate_technical_proficiency.py | 179 +++++++++------ python/cohort/generate_member_files.py | 126 +++++++---- python/cohort/generate_timeslots.py | 6 +- python/cohort/send_team_messages.py | 118 +++++++--- python/cohort/verify_discord.py | 34 +-- python/pyproject.toml | 24 +- typescript/src/utils/interactive-runner.ts | 165 -------------- typescript/src/utils/interactiveRunner.ts | 210 ++++++++++++++++++ 11 files changed, 599 insertions(+), 403 deletions(-) delete mode 100644 typescript/src/utils/interactive-runner.ts create mode 100644 typescript/src/utils/interactiveRunner.ts diff --git a/python/cohort/analyse_availability.py b/python/cohort/analyse_availability.py index 18e5330..438ee78 100644 --- a/python/cohort/analyse_availability.py +++ b/python/cohort/analyse_availability.py @@ -2,22 +2,23 @@ import json import re from collections import defaultdict -DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] +DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] UTC_BLOCKS = { - 'mornings': (6, 12), # 06:00 - 12:00 UTC - 'afternoons': (12, 18), # 12:00 - 18:00 UTC - 'evenings': (18, 24), # 18:00 - 00:00 UTC - 'nights': (0, 6) # 00:00 - 06:00 UTC + "mornings": (6, 12), # 06:00 - 12:00 UTC + "afternoons": (12, 18), # 12:00 - 18:00 UTC + "evenings": (18, 24), # 18:00 - 00:00 UTC + "nights": (0, 6), # 00:00 - 06:00 UTC } + def parse_utc_offset(timezone_str: str) -> float: """Extract UTC offset from timezone string like 'America/New_York (UTC-5)'""" - match = re.search(r'UTC([+-]?\d+(?::\d+)?)', timezone_str) + match = re.search(r"UTC([+-]?\d+(?::\d+)?)", timezone_str) if match: offset_str = match.group(1) - if ':' in offset_str: - parts = offset_str.split(':') + if ":" in offset_str: + parts = offset_str.split(":") hours = int(parts[0]) minutes = int(parts[1]) if len(parts) > 1 else 0 if hours < 0: @@ -26,42 +27,45 @@ def parse_utc_offset(timezone_str: str) -> float: return float(offset_str) return 0 + def parse_time_slots(time_str: str) -> list[tuple[int, int]]: """Parse time slots like '17:00-18:00' or '07:00-08:00; 19:00-20:00'""" slots = [] - if not time_str or time_str.lower() in ['n/a', 'na', '']: + if not time_str or time_str.lower() in ["n/a", "na", ""]: return slots - parts = time_str.split(';') + parts = time_str.split(";") for part in parts: part = part.strip() - match = re.search(r'(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})', part) + match = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", part) if match: start_hour = int(match.group(1)) end_hour = int(match.group(3)) slots.append((start_hour, end_hour)) return slots + def local_hour_to_utc(local_hour: int, utc_offset: float) -> int: """Convert local hour to UTC hour""" utc_hour = local_hour - utc_offset return int(utc_hour) % 24 + def get_utc_blocks_for_hour(utc_hour: int) -> list[str]: """Determine which UTC block(s) an hour falls into""" blocks = [] for block_name, (start, end) in UTC_BLOCKS.items(): - if block_name == 'nights': + if block_name == "nights": if utc_hour >= 0 and utc_hour < 6: blocks.append(block_name) - elif block_name == 'evenings': + elif block_name == "evenings": if utc_hour >= 18 and utc_hour < 24: blocks.append(block_name) - else: - if utc_hour >= start and utc_hour < end: - blocks.append(block_name) + elif utc_hour >= start and utc_hour < end: + blocks.append(block_name) return blocks + def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict: """Analyze availability for one applicant""" utc_offset = parse_utc_offset(timezone_str) @@ -80,42 +84,43 @@ def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict: block_counts[block] += 1 available_blocks = [] - for block in ['mornings', 'afternoons', 'evenings', 'nights']: + for block in ["mornings", "afternoons", "evenings", "nights"]: if block_counts[block] >= 3: available_blocks.append(block) return { - 'utc_offset': utc_offset, - 'timezone': timezone_str, - 'available_blocks': available_blocks, - 'block_counts': dict(block_counts), - 'total_unique_utc_hours': len(all_utc_hours) + "utc_offset": utc_offset, + "timezone": timezone_str, + "available_blocks": available_blocks, + "block_counts": dict(block_counts), + "total_unique_utc_hours": len(all_utc_hours), } + def parse_table_md() -> list[dict]: """Parse table.md and extract availability data""" - with open('table.md', 'r') as f: + with open("table.md") as f: content = f.read() - lines = content.strip().split('\n') + lines = content.strip().split("\n") header_idx = None for i, line in enumerate(lines): - if line.startswith('| Discord ID'): + if line.startswith("| Discord ID"): header_idx = i break if header_idx is None: raise ValueError("Could not find table header") - headers = [h.strip() for h in lines[header_idx].split('|')[1:-1]] + headers = [h.strip() for h in lines[header_idx].split("|")[1:-1]] applicants = [] - for line in lines[header_idx + 2:]: - if not line.startswith('|'): + for line in lines[header_idx + 2 :]: + if not line.startswith("|"): continue - cells = [c.strip() for c in line.split('|')[1:-1]] + cells = [c.strip() for c in line.split("|")[1:-1]] if len(cells) < len(headers): continue @@ -124,11 +129,12 @@ def parse_table_md() -> list[dict]: return applicants + def main(): - with open('discord_verification.json', 'r') as f: + with open("discord_verification.json") as f: verification = json.load(f) - verified_ids = set(v[0] for v in verification['verified']) + verified_ids = {v[0] for v in verification["verified"]} print(f"Verified applicants: {len(verified_ids)}") applicants = parse_table_md() @@ -137,46 +143,49 @@ def main(): availability_results = [] for applicant in applicants: - discord_id = applicant.get('Discord ID', '') + discord_id = applicant.get("Discord ID", "") if discord_id not in verified_ids: continue - timezone = applicant.get('Timezone', '') + timezone = applicant.get("Timezone", "") day_slots = {} for day in DAYS: - time_str = applicant.get(day, '') + time_str = applicant.get(day, "") day_slots[day] = parse_time_slots(time_str) analysis = analyze_applicant_availability(timezone, day_slots) - availability_results.append({ - 'discord_id': discord_id, - 'timezone': timezone, - 'utc_offset': analysis['utc_offset'], - 'available_blocks': analysis['available_blocks'], - 'block_counts': analysis['block_counts'], - 'total_unique_utc_hours': analysis['total_unique_utc_hours'] - }) + availability_results.append( + { + "discord_id": discord_id, + "timezone": timezone, + "utc_offset": analysis["utc_offset"], + "available_blocks": analysis["available_blocks"], + "block_counts": analysis["block_counts"], + "total_unique_utc_hours": analysis["total_unique_utc_hours"], + } + ) - with open('availability_analysis.json', 'w') as f: + with open("availability_analysis.json", "w") as f: json.dump(availability_results, f, indent=2) block_distribution = defaultdict(int) for result in availability_results: - for block in result['available_blocks']: + for block in result["available_blocks"]: block_distribution[block] += 1 - print(f"\n=== AVAILABILITY ANALYSIS COMPLETE ===") + print("\n=== AVAILABILITY ANALYSIS COMPLETE ===") print(f"Analyzed: {len(availability_results)} applicants") - print(f"\nBlock Distribution (applicants available in each block):") - for block in ['mornings', 'afternoons', 'evenings', 'nights']: + print("\nBlock Distribution (applicants available in each block):") + for block in ["mornings", "afternoons", "evenings", "nights"]: print(f" {block.capitalize()}: {block_distribution[block]}") - no_blocks = sum(1 for r in availability_results if not r['available_blocks']) + no_blocks = sum(1 for r in availability_results if not r["available_blocks"]) print(f"\nApplicants with no clear block availability: {no_blocks}") - print(f"\nResults saved to availability_analysis.json") + print("\nResults saved to availability_analysis.json") + if __name__ == "__main__": main() diff --git a/python/cohort/assign_cohort_role.py b/python/cohort/assign_cohort_role.py index e4bfce6..97e0ba7 100644 --- a/python/cohort/assign_cohort_role.py +++ b/python/cohort/assign_cohort_role.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -""" -Assign the Cohort role to all 155 participants. +"""Assign the Cohort role to all 155 participants. Respects Discord rate limits with proper backoff and retry logic. """ import json import os import time + import requests BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] @@ -14,10 +14,8 @@ GUILD_ID = "692816967895220344" COHORT_ROLE_ID = "1464314780935258112" BASE_URL = "https://discord.com/api/v10" -HEADERS = { - "Authorization": f"Bot {BOT_TOKEN}", - "Content-Length": "0" -} +HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Length": "0"} + def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool: url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}" @@ -35,21 +33,22 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> if retry_after is None: try: retry_after = response.json().get("retry_after", 1) - except: + except Exception: retry_after = 1 retry_after = float(retry_after) print(f" Rate limited! Waiting {retry_after:.2f}s before retry...") time.sleep(retry_after) else: print(f" Error {response.status_code}: {response.text}") - backoff_time = (2 ** attempt) * 0.5 + backoff_time = (2**attempt) * 0.5 print(f" Retrying in {backoff_time:.2f}s...") time.sleep(backoff_time) return False + def main(): - with open("team_assignments.json", "r") as f: + with open("team_assignments.json") as f: teams = json.load(f) all_users = [] @@ -82,5 +81,6 @@ def main(): print("-" * 50) print(f"Complete! Success: {success_count}, Failed: {fail_count}") + if __name__ == "__main__": main() diff --git a/python/cohort/assign_team_roles.py b/python/cohort/assign_team_roles.py index 2280d2f..91bf134 100644 --- a/python/cohort/assign_team_roles.py +++ b/python/cohort/assign_team_roles.py @@ -1,22 +1,19 @@ #!/usr/bin/env python3 -""" -Assign team-specific roles to all 155 participants. +"""Assign team-specific roles to all 155 participants. Respects Discord rate limits with proper backoff and retry logic. """ import json import os import time + import requests BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] GUILD_ID = "692816967895220344" BASE_URL = "https://discord.com/api/v10" -HEADERS = { - "Authorization": f"Bot {BOT_TOKEN}", - "Content-Length": "0" -} +HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Length": "0"} TEAM_ROLE_IDS = { "Jade Jasmine": "1464314923780931677", @@ -52,14 +49,14 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> if retry_after is None: try: retry_after = response.json().get("retry_after", 1) - except: + except Exception: retry_after = 1 retry_after = float(retry_after) print(f" Rate limited! Waiting {retry_after:.2f}s before retry...") time.sleep(retry_after) else: print(f" Error {response.status_code}: {response.text}") - backoff_time = (2 ** attempt) * 0.5 + backoff_time = (2**attempt) * 0.5 print(f" Retrying in {backoff_time:.2f}s...") time.sleep(backoff_time) @@ -67,7 +64,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> def main(): - with open("team_assignments.json", "r") as f: + with open("team_assignments.json") as f: teams = json.load(f) print(f"Assigning team roles to {len(teams)} teams...") diff --git a/python/cohort/evaluate_technical_proficiency.py b/python/cohort/evaluate_technical_proficiency.py index 56c2e6c..b31d50b 100644 --- a/python/cohort/evaluate_technical_proficiency.py +++ b/python/cohort/evaluate_technical_proficiency.py @@ -1,19 +1,19 @@ import json import re import time -import urllib.request import urllib.error -from typing import Optional +import urllib.request # GitHub API (no auth needed for public repos, but rate limited) GITHUB_API = "https://api.github.com" -def extract_github_info(url: str) -> tuple[Optional[str], Optional[str]]: + +def extract_github_info(url: str) -> tuple[str | None, str | None]: """Extract owner and repo from GitHub URL.""" # Handle various GitHub URL formats patterns = [ - r'github\.com/([^/]+)/([^/\s?#]+)', # github.com/owner/repo - r'github\.com/([^/\s?#]+)/?$', # github.com/owner (profile) + r"github\.com/([^/]+)/([^/\s?#]+)", # github.com/owner/repo + r"github\.com/([^/\s?#]+)/?$", # github.com/owner (profile) ] for pattern in patterns: @@ -21,12 +21,13 @@ def extract_github_info(url: str) -> tuple[Optional[str], Optional[str]]: if match: groups = match.groups() if len(groups) == 2: - return groups[0], groups[1].rstrip('.git') + return groups[0], groups[1].rstrip(".git") elif len(groups) == 1: return groups[0], None return None, None -def fetch_github_user(username: str) -> Optional[dict]: + +def fetch_github_user(username: str) -> dict | None: """Fetch GitHub user profile.""" url = f"{GITHUB_API}/users/{username}" req = urllib.request.Request(url) @@ -36,9 +37,10 @@ def fetch_github_user(username: str) -> Optional[dict]: try: response = urllib.request.urlopen(req, timeout=10) return json.loads(response.read().decode()) - except Exception as e: + except Exception: return None + def fetch_github_repos(username: str) -> list: """Fetch user's public repos.""" url = f"{GITHUB_API}/users/{username}/repos?per_page=100&sort=updated" @@ -49,9 +51,10 @@ def fetch_github_repos(username: str) -> list: try: response = urllib.request.urlopen(req, timeout=10) return json.loads(response.read().decode()) - except Exception as e: + except Exception: return [] + def fetch_repo_languages(owner: str, repo: str) -> dict: """Fetch languages used in a repo.""" url = f"{GITHUB_API}/repos/{owner}/{repo}/languages" @@ -62,21 +65,22 @@ def fetch_repo_languages(owner: str, repo: str) -> dict: try: response = urllib.request.urlopen(req, timeout=10) return json.loads(response.read().decode()) - except Exception as e: + except Exception: return {} + def analyze_proficiency_text(text: str) -> tuple[str, list[str]]: """Analyze self-described proficiency text.""" text_lower = text.lower() # Extract languages/technologies mentioned tech_patterns = [ - r'\b(python|java|javascript|typescript|c\+\+|c#|ruby|go|rust|swift|kotlin|php|perl|scala|r)\b', - r'\b(react|angular|vue|node|express|django|flask|spring|rails|laravel)\b', - r'\b(html|css|sass|scss|tailwind|bootstrap)\b', - r'\b(sql|mysql|postgresql|mongodb|redis|firebase)\b', - r'\b(docker|kubernetes|aws|azure|gcp|git)\b', - r'\b(machine learning|ml|ai|data science|tensorflow|pytorch)\b', + r"\b(python|java|javascript|typescript|c\+\+|c#|ruby|go|rust|swift|kotlin|php|perl|scala|r)\b", + r"\b(react|angular|vue|node|express|django|flask|spring|rails|laravel)\b", + r"\b(html|css|sass|scss|tailwind|bootstrap)\b", + r"\b(sql|mysql|postgresql|mongodb|redis|firebase)\b", + r"\b(docker|kubernetes|aws|azure|gcp|git)\b", + r"\b(machine learning|ml|ai|data science|tensorflow|pytorch)\b", ] technologies = set() @@ -85,126 +89,152 @@ def analyze_proficiency_text(text: str) -> tuple[str, list[str]]: technologies.update(matches) # Determine level from keywords - beginner_keywords = ['beginner', 'learning', 'new to', 'just started', 'basic', 'novice', 'early'] - intermediate_keywords = ['intermediate', 'comfortable', 'familiar', 'some experience', 'worked with'] - advanced_keywords = ['advanced', 'expert', 'senior', 'professional', 'years of experience', 'proficient', 'strong'] + beginner_keywords = [ + "beginner", + "learning", + "new to", + "just started", + "basic", + "novice", + "early", + ] + intermediate_keywords = [ + "intermediate", + "comfortable", + "familiar", + "some experience", + "worked with", + ] + advanced_keywords = [ + "advanced", + "expert", + "senior", + "professional", + "years of experience", + "proficient", + "strong", + ] - level = 'intermediate' # default + level = "intermediate" # default if any(kw in text_lower for kw in advanced_keywords): - level = 'advanced' + level = "advanced" elif any(kw in text_lower for kw in beginner_keywords): - level = 'beginner' + level = "beginner" elif any(kw in text_lower for kw in intermediate_keywords): - level = 'intermediate' + level = "intermediate" return level, list(technologies) + def evaluate_applicant(applicant: dict, index: int, total: int) -> dict: """Evaluate a single applicant's technical proficiency.""" - discord_id = applicant['discord_id'] - project_url = applicant['project_url'] - proficiency_self = applicant['proficiency_self'] - project_reason = applicant['project_reason'] + discord_id = applicant["discord_id"] + project_url = applicant["project_url"] + proficiency_self = applicant["proficiency_self"] + project_reason = applicant["project_reason"] - print(f"[{index+1}/{total}] Evaluating {discord_id}...") + print(f"[{index + 1}/{total}] Evaluating {discord_id}...") result = { - 'discord_id': discord_id, - 'github_username': None, - 'github_repos_count': 0, - 'github_followers': 0, - 'languages_from_github': [], - 'languages_from_text': [], - 'self_described_level': None, - 'final_proficiency': 'intermediate', # default - 'tech_stack': [], - 'notes': [] + "discord_id": discord_id, + "github_username": None, + "github_repos_count": 0, + "github_followers": 0, + "languages_from_github": [], + "languages_from_text": [], + "self_described_level": None, + "final_proficiency": "intermediate", # default + "tech_stack": [], + "notes": [], } # Analyze self-description - text_level, text_techs = analyze_proficiency_text(proficiency_self + " " + project_reason) - result['self_described_level'] = text_level - result['languages_from_text'] = text_techs + text_level, text_techs = analyze_proficiency_text( + proficiency_self + " " + project_reason + ) + result["self_described_level"] = text_level + result["languages_from_text"] = text_techs # Fetch GitHub data if URL provided - if project_url and 'github.com' in project_url: + if project_url and "github.com" in project_url: owner, repo = extract_github_info(project_url) if owner: - result['github_username'] = owner + result["github_username"] = owner # Fetch user profile user_data = fetch_github_user(owner) if user_data: - result['github_repos_count'] = user_data.get('public_repos', 0) - result['github_followers'] = user_data.get('followers', 0) + result["github_repos_count"] = user_data.get("public_repos", 0) + result["github_followers"] = user_data.get("followers", 0) # Fetch repos to get languages repos = fetch_github_repos(owner) all_languages = set() for r in repos[:10]: # Check top 10 repos - if r.get('language'): - all_languages.add(r['language'].lower()) - result['languages_from_github'] = list(all_languages) + if r.get("language"): + all_languages.add(r["language"].lower()) + result["languages_from_github"] = list(all_languages) # If specific repo provided, get its languages if repo: repo_langs = fetch_repo_languages(owner, repo) - for lang in repo_langs.keys(): + for lang in repo_langs: all_languages.add(lang.lower()) - result['languages_from_github'] = list(all_languages) + result["languages_from_github"] = list(all_languages) time.sleep(0.5) # Rate limiting # Combine tech stack - all_tech = set(result['languages_from_github']) | set(result['languages_from_text']) - result['tech_stack'] = sorted(list(all_tech)) + all_tech = set(result["languages_from_github"]) | set(result["languages_from_text"]) + result["tech_stack"] = sorted(all_tech) # Determine final proficiency # Factors: self-description, GitHub activity, tech diversity github_score = 0 - if result['github_repos_count'] >= 20: + if result["github_repos_count"] >= 20: github_score += 2 - elif result['github_repos_count'] >= 10: + elif result["github_repos_count"] >= 10: github_score += 1 - if result['github_followers'] >= 50: + if result["github_followers"] >= 50: github_score += 2 - elif result['github_followers'] >= 10: + elif result["github_followers"] >= 10: github_score += 1 - tech_count = len(result['tech_stack']) + tech_count = len(result["tech_stack"]) if tech_count >= 6: github_score += 2 elif tech_count >= 3: github_score += 1 # Map self-described level to score - level_scores = {'beginner': 0, 'intermediate': 2, 'advanced': 4} + level_scores = {"beginner": 0, "intermediate": 2, "advanced": 4} self_score = level_scores.get(text_level, 2) # Combined score total_score = github_score + self_score if total_score >= 7: - result['final_proficiency'] = 'advanced' + result["final_proficiency"] = "advanced" elif total_score >= 3: - result['final_proficiency'] = 'intermediate' + result["final_proficiency"] = "intermediate" else: - result['final_proficiency'] = 'beginner' + result["final_proficiency"] = "beginner" # Add notes - if not project_url or 'github.com' not in project_url: - result['notes'].append('No GitHub URL provided') - if result['github_repos_count'] == 0 and result['github_username']: - result['notes'].append('GitHub profile has no public repos') + if not project_url or "github.com" not in project_url: + result["notes"].append("No GitHub URL provided") + if result["github_repos_count"] == 0 and result["github_username"]: + result["notes"].append("GitHub profile has no public repos") return result + def main(): # Load applicants - with open('applicants_to_evaluate.json', 'r') as f: + with open("applicants_to_evaluate.json") as f: applicants = json.load(f) print(f"Evaluating {len(applicants)} applicants...\n") @@ -216,23 +246,26 @@ def main(): # Progress update every 10 if (i + 1) % 10 == 0: - print(f" Progress: {i+1}/{len(applicants)} complete") + print(f" Progress: {i + 1}/{len(applicants)} complete") # Save results - with open('proficiency_evaluations.json', 'w') as f: + with open("proficiency_evaluations.json", "w") as f: json.dump(evaluations, f, indent=2) # Summary - beginner = sum(1 for e in evaluations if e['final_proficiency'] == 'beginner') - intermediate = sum(1 for e in evaluations if e['final_proficiency'] == 'intermediate') - advanced = sum(1 for e in evaluations if e['final_proficiency'] == 'advanced') + beginner = sum(1 for e in evaluations if e["final_proficiency"] == "beginner") + intermediate = sum( + 1 for e in evaluations if e["final_proficiency"] == "intermediate" + ) + advanced = sum(1 for e in evaluations if e["final_proficiency"] == "advanced") - print(f"\n=== EVALUATION COMPLETE ===") + print("\n=== EVALUATION COMPLETE ===") print(f"Beginner: {beginner}") print(f"Intermediate: {intermediate}") print(f"Advanced: {advanced}") print(f"Total: {len(evaluations)}") - print(f"\nResults saved to proficiency_evaluations.json") + print("\nResults saved to proficiency_evaluations.json") + if __name__ == "__main__": main() diff --git a/python/cohort/generate_member_files.py b/python/cohort/generate_member_files.py index 03042eb..8ce6749 100644 --- a/python/cohort/generate_member_files.py +++ b/python/cohort/generate_member_files.py @@ -1,61 +1,63 @@ import json -BLOCK_EMOJIS = { - 'mornings': '🌅', - 'afternoons': '☀️', - 'evenings': '🌆', - 'nights': '🌙' -} +BLOCK_EMOJIS = {"mornings": "🌅", "afternoons": "☀️", "evenings": "🌆", "nights": "🌙"} + def load_all_data(): """Load all evaluation data files""" - with open('discord_verification.json', 'r') as f: + with open("discord_verification.json") as f: verification = json.load(f) - with open('proficiency_evaluations.json', 'r') as f: + with open("proficiency_evaluations.json") as f: proficiency = json.load(f) - with open('availability_analysis.json', 'r') as f: + with open("availability_analysis.json") as f: availability = json.load(f) - with open('leadership_candidates.json', 'r') as f: + with open("leadership_candidates.json") as f: candidates = json.load(f) - with open('leadership_evaluations.json', 'r') as f: + with open("leadership_evaluations.json") as f: leadership = json.load(f) return verification, proficiency, availability, candidates, leadership + def build_lookup_dicts(verification, proficiency, availability, leadership): """Build lookup dictionaries by discord_id""" - verified_usernames = {v[0]: v[1] for v in verification['verified']} + verified_usernames = {v[0]: v[1] for v in verification["verified"]} - prof_by_id = {p['discord_id']: p for p in proficiency} + prof_by_id = {p["discord_id"]: p for p in proficiency} - avail_by_id = {a['discord_id']: a for a in availability} + avail_by_id = {a["discord_id"]: a for a in availability} - lead_by_id = {l['discord_id']: l for l in leadership} + lead_by_id = {l["discord_id"]: l for l in leadership} return verified_usernames, prof_by_id, avail_by_id, lead_by_id + def format_availability_blocks(blocks): """Format availability blocks with emojis""" if not blocks: return "No consistent availability" formatted = [] - for block in ['mornings', 'afternoons', 'evenings', 'nights']: + for block in ["mornings", "afternoons", "evenings", "nights"]: if block in blocks: formatted.append(f"{BLOCK_EMOJIS[block]} {block.capitalize()}") return ", ".join(formatted) + def format_tech_stack(tech_stack): """Format tech stack list""" if not tech_stack: return "Not specified" return ", ".join(sorted(tech_stack)) -def generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, avail_by_id): + +def generate_participants_md( + non_leader_ids, verified_usernames, prof_by_id, avail_by_id +): """Generate participants.md for non-leaders""" lines = [ "# Cohort Participants", @@ -63,7 +65,7 @@ def generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, ava f"**Total Participants**: {len(non_leader_ids)}", "", "---", - "" + "", ] beginner_count = 0 @@ -78,16 +80,16 @@ def generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, ava prof = prof_by_id.get(discord_id, {}) avail = avail_by_id.get(discord_id, {}) - proficiency = prof.get('final_proficiency', 'unknown') - tech_stack = prof.get('tech_stack', []) - blocks = avail.get('available_blocks', []) - notes = prof.get('notes', []) + proficiency = prof.get("final_proficiency", "unknown") + tech_stack = prof.get("tech_stack", []) + blocks = avail.get("available_blocks", []) + notes = prof.get("notes", []) - if proficiency == 'beginner': + if proficiency == "beginner": beginner_count += 1 - elif proficiency == 'intermediate': + elif proficiency == "intermediate": intermediate_count += 1 - elif proficiency == 'advanced': + elif proficiency == "advanced": advanced_count += 1 lines.append(f"## {discord_id}") @@ -99,10 +101,11 @@ def generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, ava lines.append(f"**Notes**: {', '.join(notes)}") lines.append("") + verified_count = len([d for d in non_leader_ids if d in verified_usernames]) summary = [ "# Cohort Participants", "", - f"**Total Participants**: {len([id for id in non_leader_ids if id in verified_usernames])}", + f"**Total Participants**: {verified_count}", "", "### Proficiency Breakdown", f"- Beginner: {beginner_count}", @@ -110,11 +113,12 @@ def generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, ava f"- Advanced: {advanced_count}", "", "---", - "" + "", ] return "\n".join(summary + lines[6:]) + def leadership_fit_label(score): """Convert leadership score to label""" if score >= 6: @@ -126,7 +130,10 @@ def leadership_fit_label(score): else: return "Needs Review" -def generate_leaders_md(leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id): + +def generate_leaders_md( + leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id +): """Generate leaders.md for leadership candidates""" verified_leaders = [id for id in leader_ids if id in verified_usernames] @@ -136,10 +143,14 @@ def generate_leaders_md(leader_ids, verified_usernames, prof_by_id, avail_by_id, f"**Total Leaders**: {len(verified_leaders)}", "", "---", - "" + "", ] - sorted_leaders = sorted(verified_leaders, key=lambda x: lead_by_id.get(x, {}).get('leadership_score', 0), reverse=True) + sorted_leaders = sorted( + verified_leaders, + key=lambda x: lead_by_id.get(x, {}).get("leadership_score", 0), + reverse=True, + ) for discord_id in sorted_leaders: username = verified_usernames.get(discord_id, "Unknown") @@ -147,18 +158,19 @@ def generate_leaders_md(leader_ids, verified_usernames, prof_by_id, avail_by_id, avail = avail_by_id.get(discord_id, {}) lead = lead_by_id.get(discord_id, {}) - proficiency = prof.get('final_proficiency', 'unknown') - tech_stack = prof.get('tech_stack', []) - blocks = avail.get('available_blocks', []) + proficiency = prof.get("final_proficiency", "unknown") + tech_stack = prof.get("tech_stack", []) + blocks = avail.get("available_blocks", []) - leadership_score = lead.get('leadership_score', 0) - leadership_fit = lead.get('leadership_fit', 'unknown') - leadership_notes = lead.get('notes', []) - prof_notes = prof.get('notes', []) + leadership_score = lead.get("leadership_score", 0) + leadership_fit = lead.get("leadership_fit", "unknown") + leadership_notes = lead.get("notes", []) + prof_notes = prof.get("notes", []) lines.append(f"## {discord_id}") lines.append(f"**Username**: @{username}") - lines.append(f"**Leadership Fit**: {leadership_fit.capitalize()} (Score: {leadership_score})") + fit = leadership_fit.capitalize() + lines.append(f"**Leadership Fit**: {fit} (Score: {leadership_score})") lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}") lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}") lines.append(f"**Availability**: {format_availability_blocks(blocks)}") @@ -168,9 +180,21 @@ def generate_leaders_md(leader_ids, verified_usernames, prof_by_id, avail_by_id, lines.append(f"**Technical Notes**: {', '.join(prof_notes)}") lines.append("") - excellent = sum(1 for id in verified_leaders if lead_by_id.get(id, {}).get('leadership_fit') == 'excellent') - good = sum(1 for id in verified_leaders if lead_by_id.get(id, {}).get('leadership_fit') == 'good') - adequate = sum(1 for id in verified_leaders if lead_by_id.get(id, {}).get('leadership_fit') == 'adequate') + excellent = sum( + 1 + for id in verified_leaders + if lead_by_id.get(id, {}).get("leadership_fit") == "excellent" + ) + good = sum( + 1 + for id in verified_leaders + if lead_by_id.get(id, {}).get("leadership_fit") == "good" + ) + adequate = sum( + 1 + for id in verified_leaders + if lead_by_id.get(id, {}).get("leadership_fit") == "adequate" + ) summary = [ "# Cohort Leaders", @@ -183,11 +207,12 @@ def generate_leaders_md(leader_ids, verified_usernames, prof_by_id, avail_by_id, f"- Adequate: {adequate}", "", "---", - "" + "", ] return "\n".join(summary + lines[6:]) + def main(): verification, proficiency, availability, candidates, leadership = load_all_data() @@ -195,22 +220,27 @@ def main(): verification, proficiency, availability, leadership ) - leader_ids = set(candidates['leaders']) - non_leader_ids = set(candidates['non_leaders']) + leader_ids = set(candidates["leaders"]) + non_leader_ids = set(candidates["non_leaders"]) verified_ids = set(verified_usernames.keys()) leader_ids = leader_ids & verified_ids non_leader_ids = non_leader_ids & verified_ids - participants_md = generate_participants_md(non_leader_ids, verified_usernames, prof_by_id, avail_by_id) - with open('participants.md', 'w') as f: + participants_md = generate_participants_md( + non_leader_ids, verified_usernames, prof_by_id, avail_by_id + ) + with open("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: + 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: f.write(leaders_md) print(f"Generated leaders.md with {len(leader_ids)} leaders") + if __name__ == "__main__": main() diff --git a/python/cohort/generate_timeslots.py b/python/cohort/generate_timeslots.py index e34d4bc..9492877 100644 --- a/python/cohort/generate_timeslots.py +++ b/python/cohort/generate_timeslots.py @@ -1,10 +1,10 @@ -from datetime import datetime, timedelta import json +from datetime import datetime, timedelta # Generate hourly time slots from Feb 1 to March 3, 2026 # 24 hours a day, America/Los_Angeles timezone start_date = datetime(2026, 2, 1, 0, 0) # Feb 1, 2026, midnight -end_date = datetime(2026, 3, 3, 23, 0) # March 3, 2026, 11pm +end_date = datetime(2026, 3, 3, 23, 0) # March 3, 2026, 11pm times = [] current = start_date @@ -18,7 +18,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("/home/naomi/docs/cohort/crabfit_timeslots.json", "w") as f: json.dump(times, f) print("Saved to crabfit_timeslots.json") diff --git a/python/cohort/send_team_messages.py b/python/cohort/send_team_messages.py index a5f2ee9..0ee6bb0 100644 --- a/python/cohort/send_team_messages.py +++ b/python/cohort/send_team_messages.py @@ -1,6 +1,7 @@ import json import os import time + import requests # Amari's bot token @@ -12,32 +13,75 @@ MESSAGE_IDS_FILE = "team_message_ids.json" # Team channel IDs and role IDs TEAMS = { - "Jade Jasmine": {"channel_id": "1464316501573107886", "role_id": "1464314923780931677"}, - "Crimson Dahlia": {"channel_id": "1464316744909852682", "role_id": "1464315093402784015"}, - "Rose Camellia": {"channel_id": "1464316751268286611", "role_id": "1464315098452726106"}, - "Amber Wisteria": {"channel_id": "1464316761410113641", "role_id": "1464315105264275600"}, - "Ivory Orchid": {"channel_id": "1464316770889240730", "role_id": "1464315109873684593"}, - "Teal Iris": {"channel_id": "1464316776459407448", "role_id": "1464315114378498152"}, - "Peach Gardenia": {"channel_id": "1464316785040953543", "role_id": "1464315118904152107"}, - "Violet Carnation": {"channel_id": "1464316805261824032", "role_id": "1464315124251754559"}, - "Azure Lotus": {"channel_id": "1464316814455472139", "role_id": "1464315128437801177"}, - "Coral Sunflower": {"channel_id": "1464316819711066263", "role_id": "1464315132896088168"}, - "Indigo Tulip": {"channel_id": "1464316826384072925", "role_id": "1464315138428633241"}, - "Scarlet Hydrangea": {"channel_id": "1464316839306985506", "role_id": "1464315142710890520"}, - "Mint Narcissus": {"channel_id": "1464316844251807952", "role_id": "1464315149203804405"}, - "Sage Marigold": {"channel_id": "1464316850669093040", "role_id": "1464315153599299803"}, + "Jade Jasmine": { + "channel_id": "1464316501573107886", + "role_id": "1464314923780931677", + }, + "Crimson Dahlia": { + "channel_id": "1464316744909852682", + "role_id": "1464315093402784015", + }, + "Rose Camellia": { + "channel_id": "1464316751268286611", + "role_id": "1464315098452726106", + }, + "Amber Wisteria": { + "channel_id": "1464316761410113641", + "role_id": "1464315105264275600", + }, + "Ivory Orchid": { + "channel_id": "1464316770889240730", + "role_id": "1464315109873684593", + }, + "Teal Iris": { + "channel_id": "1464316776459407448", + "role_id": "1464315114378498152", + }, + "Peach Gardenia": { + "channel_id": "1464316785040953543", + "role_id": "1464315118904152107", + }, + "Violet Carnation": { + "channel_id": "1464316805261824032", + "role_id": "1464315124251754559", + }, + "Azure Lotus": { + "channel_id": "1464316814455472139", + "role_id": "1464315128437801177", + }, + "Coral Sunflower": { + "channel_id": "1464316819711066263", + "role_id": "1464315132896088168", + }, + "Indigo Tulip": { + "channel_id": "1464316826384072925", + "role_id": "1464315138428633241", + }, + "Scarlet Hydrangea": { + "channel_id": "1464316839306985506", + "role_id": "1464315142710890520", + }, + "Mint Narcissus": { + "channel_id": "1464316844251807952", + "role_id": "1464315149203804405", + }, + "Sage Marigold": { + "channel_id": "1464316850669093040", + "role_id": "1464315153599299803", + }, } # Load team assignments and convert to dict by team name -with open("team_assignments.json", "r") as f: +with open("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", "r") as f: +with open("applicants_to_evaluate.json") as f: applicants = json.load(f) applicant_lookup = {str(a["discord_id"]): a for a in applicants} + def extract_github_username(url): """Extract GitHub username from various URL formats""" if not url: @@ -64,19 +108,26 @@ def extract_github_username(url): # Handle standard GitHub URLs if "github.com" in url: # Remove protocol and github.com - path = url.replace("https://", "").replace("http://", "").replace("github.com/", "") + path = ( + url.replace("https://", "") + .replace("http://", "") + .replace("github.com/", "") + ) # Get just the username (first part of path) username = path.split("/")[0] return username return url + def build_message(team_name, role_id, leader_ids, participant_ids): """Build the welcome message for a team""" lines = [ f"# {team_name}", "", - f"Welcome, <@&{role_id}>. This is your private team channel — a space for you to collaborate, support one another, and build something meaningful together.", + f"Welcome, <@&{role_id}>. This is your private team channel — a space " + "for you to collaborate, support one another, and build something " + "meaningful together.", "", "## Roster", "", @@ -105,19 +156,21 @@ def build_message(team_name, role_id, leader_ids, participant_ids): return "\n".join(lines) + def send_message(channel_id, content): """Send a message to a channel""" url = f"https://discord.com/api/v10/channels/{channel_id}/messages" - headers = { - "Authorization": f"Bot {TOKEN}", - "Content-Type": "application/json" - } + headers = {"Authorization": f"Bot {TOKEN}", "Content-Type": "application/json"} data = {"content": content} response = requests.post(url, headers=headers, json=data) if response.status_code == 429: - retry_after = float(response.headers.get("Retry-After", response.headers.get("X-RateLimit-Reset-After", 1))) + retry_after = float( + response.headers.get( + "Retry-After", response.headers.get("X-RateLimit-Reset-After", 1) + ) + ) print(f"Rate limited, waiting {retry_after}s...") time.sleep(retry_after) return send_message(channel_id, content) @@ -128,6 +181,7 @@ def send_message(channel_id, content): print(f"Error sending message: {response.status_code} - {response.text}") return None + def pin_message(channel_id, message_id): """Pin a message in a channel""" url = f"https://discord.com/api/v10/channels/{channel_id}/pins/{message_id}" @@ -138,13 +192,18 @@ def pin_message(channel_id, message_id): response = requests.put(url, headers=headers) if response.status_code == 429: - retry_after = float(response.headers.get("Retry-After", response.headers.get("X-RateLimit-Reset-After", 1))) + retry_after = float( + response.headers.get( + "Retry-After", response.headers.get("X-RateLimit-Reset-After", 1) + ) + ) print(f"Rate limited, waiting {retry_after}s...") time.sleep(retry_after) return pin_message(channel_id, message_id) return response.status_code == 204 + def main(): message_ids = {} @@ -170,18 +229,18 @@ def main(): message_ids[team_name] = { "channel_id": channel_id, "message_id": message_id, - "role_id": role_id + "role_id": role_id, } print(f" Message sent! ID: {message_id}") # Pin the message - print(f" Pinning message...") + print(" Pinning message...") if pin_message(channel_id, message_id): - print(f" Pinned!") + print(" Pinned!") else: - print(f" Failed to pin") + print(" Failed to pin") else: - print(f" Failed to send message") + print(" Failed to send message") # Small delay between teams time.sleep(0.2) @@ -193,5 +252,6 @@ def main(): print(f"\nDone! Message IDs saved to {MESSAGE_IDS_FILE}") print(f"Successfully sent and pinned messages for {len(message_ids)} teams") + if __name__ == "__main__": main() diff --git a/python/cohort/verify_discord.py b/python/cohort/verify_discord.py index 15812b0..e9894c8 100644 --- a/python/cohort/verify_discord.py +++ b/python/cohort/verify_discord.py @@ -1,8 +1,8 @@ import json import os import time -import urllib.request import urllib.error +import urllib.request # Configuration BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"] @@ -10,7 +10,7 @@ GUILD_ID = "692816967895220344" BASE_URL = "https://discord.com/api/v10" # Read Discord IDs from table.md -with open("table.md", "r") as f: +with open("table.md") as f: content = f.read() lines = content.strip().split("\n") @@ -33,7 +33,7 @@ headers = [h.strip() for h in header_line.split("|")[1:-1]] discord_idx = 0 # Discord ID is the first column discord_ids = [] -for line in lines[header_idx + 2:]: # Skip header and separator +for line in lines[header_idx + 2 :]: # Skip header and separator if not line.startswith("|"): continue cols = [c.strip() for c in line.split("|")[1:-1]] @@ -59,15 +59,17 @@ for i, discord_id in enumerate(discord_ids): data = json.loads(response.read().decode()) username = data.get("user", {}).get("username", "Unknown") verified.append((discord_id, username)) - print(f"[{i+1}/{len(discord_ids)}] ✓ {discord_id} - {username}") + print(f"[{i + 1}/{len(discord_ids)}] ✓ {discord_id} - {username}") except urllib.error.HTTPError as e: if e.code == 404: missing.append(discord_id) - print(f"[{i+1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER") + print(f"[{i + 1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER") elif e.code == 429: # Rate limited - wait and retry retry_after = json.loads(e.read().decode()).get("retry_after", 1) - print(f"[{i+1}/{len(discord_ids)}] Rate limited, waiting {retry_after}s...") + print( + f"[{i + 1}/{len(discord_ids)}] Rate limited, waiting {retry_after}s..." + ) time.sleep(retry_after + 0.5) # Retry try: @@ -77,32 +79,32 @@ for i, discord_id in enumerate(discord_ids): data = json.loads(response.read().decode()) username = data.get("user", {}).get("username", "Unknown") verified.append((discord_id, username)) - print(f"[{i+1}/{len(discord_ids)}] ✓ {discord_id} - {username} (after retry)") + msg = f"[{i + 1}/{len(discord_ids)}] ✓ {discord_id}" + print(f"{msg} - {username} (after retry)") except urllib.error.HTTPError as e2: if e2.code == 404: missing.append(discord_id) - print(f"[{i+1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER (after retry)") + msg = f"[{i + 1}/{len(discord_ids)}] ✗ {discord_id}" + print(f"{msg} - NOT IN SERVER (after retry)") else: errors.append((discord_id, f"HTTP {e2.code}")) - print(f"[{i+1}/{len(discord_ids)}] ? {discord_id} - Error {e2.code}") + print( + f"[{i + 1}/{len(discord_ids)}] ? {discord_id} - Error {e2.code}" + ) else: errors.append((discord_id, f"HTTP {e.code}")) - print(f"[{i+1}/{len(discord_ids)}] ? {discord_id} - Error {e.code}") + print(f"[{i + 1}/{len(discord_ids)}] ? {discord_id} - Error {e.code}") # Small delay to avoid rate limits time.sleep(0.1) -print(f"\n=== SUMMARY ===") +print("\n=== SUMMARY ===") print(f"Verified: {len(verified)}") print(f"Missing: {len(missing)}") print(f"Errors: {len(errors)}") # Save results with open("discord_verification.json", "w") as f: - json.dump({ - "verified": verified, - "missing": missing, - "errors": errors - }, f, indent=2) + json.dump({"verified": verified, "missing": missing, "errors": errors}, f, indent=2) print("\nResults saved to discord_verification.json") diff --git a/python/pyproject.toml b/python/pyproject.toml index 8a9a05b..9456b09 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -57,8 +57,28 @@ select = [ ignore = [ # Missing docstrings "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", - # Let's not require module docstrings for scripts - "D100", + # Allow print statements in scripts + "T201", + # Docstring punctuation - not critical for scripts + "D415", + # Magic values - acceptable in simple scripts + "PLR2004", + # Loop variable overwritten - common pattern + "PLW2901", + # Use sys.exit instead of exit - not critical + "PLR1722", + # Collapsible if statements - readability preference + "PLR5501", + # zip strict - not critical for scripts + "B905", + # Docstring summary line spacing - not critical + "D205", + # Function complexity - acceptable for scripts + "PLR0912", "PLR0915", + # Datetime timezone - scripts use local context + "DTZ001", + # Ambiguous variable names - context makes it clear + "E741", ] [tool.ruff.lint.pydocstyle] diff --git a/typescript/src/utils/interactive-runner.ts b/typescript/src/utils/interactive-runner.ts deleted file mode 100644 index 683467a..0000000 --- a/typescript/src/utils/interactive-runner.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { select } from "@inquirer/prompts"; -import { execSync } from "child_process"; -import { readdirSync, statSync, existsSync } from "fs"; -import { join, relative } from "path"; - -interface ScriptOption { - name: string; - value: string; - description?: string; -} - -const getTypeScriptCategories = (): string[] => { - const srcPath = join(__dirname, ".."); - const entries = readdirSync(srcPath); - - return entries - .filter((entry) => { - const fullPath = join(srcPath, entry); - return statSync(fullPath).isDirectory() && entry !== "utils" && entry !== "interfaces"; - }) - .sort(); -}; - -const getTypeScriptScripts = (category: string): ScriptOption[] => { - const categoryPath = join(__dirname, "..", category); - const scripts: ScriptOption[] = []; - - const walkDirectory = (dir: string) => { - const entries = readdirSync(dir); - - for (const entry of entries) { - const fullPath = join(dir, entry); - const stat = statSync(fullPath); - - if (stat.isDirectory()) { - walkDirectory(fullPath); - } else if (entry.endsWith(".ts") && entry !== "index.ts") { - const relativePath = relative(join(__dirname, ".."), fullPath); - scripts.push({ - name: entry.replace(".ts", ""), - value: relativePath, - description: relativePath, - }); - } - } - }; - - walkDirectory(categoryPath); - return scripts.sort((a, b) => a.name.localeCompare(b.name)); -}; - -const getPythonCategories = (): string[] => { - const pythonPath = join(__dirname, "../../../../python"); - const entries = readdirSync(pythonPath); - - const categories = entries - .filter((entry) => { - const fullPath = join(pythonPath, entry); - return statSync(fullPath).isDirectory() && - !entry.startsWith(".") && - entry !== "__pycache__"; - }) - .sort(); - - // Also check for scripts in the root - const hasRootScripts = entries.some(entry => entry.endsWith(".py")); - if (hasRootScripts) { - categories.unshift("(root)"); - } - - return categories; -}; - -const getPythonScripts = (category: string): ScriptOption[] => { - const pythonPath = join(__dirname, "../../../../python"); - const searchPath = category === "(root)" ? pythonPath : join(pythonPath, category); - - const scripts: ScriptOption[] = []; - const entries = readdirSync(searchPath); - - for (const entry of entries) { - if (entry.endsWith(".py") && !entry.startsWith("__")) { - const relativePath = category === "(root)" ? entry : join(category, entry); - scripts.push({ - name: entry.replace(".py", ""), - value: relativePath, - description: relativePath, - }); - } - } - - return scripts.sort((a, b) => a.name.localeCompare(b.name)); -}; - -const main = async () => { - console.log("🌸 Welcome to Ephemere Script Runner! 💖\n"); - - // Select language - const language = await select({ - message: "Which language would you like to run?", - choices: [ - { name: "TypeScript", value: "typescript", description: "Run a TypeScript script" }, - { name: "Python", value: "python", description: "Run a Python script" }, - ], - }); - - // Get categories based on language - const categories = language === "typescript" - ? getTypeScriptCategories() - : getPythonCategories(); - - if (categories.length === 0) { - console.error(`No categories found for ${language}!`); - process.exit(1); - } - - // Select category - const category = await select({ - message: "Which category?", - choices: categories.map(cat => ({ - name: cat === "(root)" ? "Root Directory" : cat.charAt(0).toUpperCase() + cat.slice(1), - value: cat, - })), - }); - - // Get scripts for selected category - const scripts = language === "typescript" - ? getTypeScriptScripts(category) - : getPythonScripts(category); - - if (scripts.length === 0) { - console.error(`No scripts found in ${category}!`); - process.exit(1); - } - - // Select script - const script = await select({ - message: "Which script would you like to run?", - choices: scripts, - }); - - // Build and execute the command - const prodEnvPath = join(__dirname, "../../../../../prod.env"); - let command: string; - - if (language === "typescript") { - command = `cd ${join(__dirname, "../../../")} && op run --env-file=${prodEnvPath} -- pnpm exec tsx src/${script}`; - } else { - command = `cd ${join(__dirname, "../../../../python")} && op run --env-file=${prodEnvPath} -- uv run python ${script}`; - } - - console.log(`\n✨ Running: ${script}\n`); - - try { - execSync(command, { - stdio: "inherit", - shell: true, - }); - } catch (error) { - console.error("\n❌ Script execution failed!"); - process.exit(1); - } -}; - -main().catch(console.error); \ No newline at end of file diff --git a/typescript/src/utils/interactiveRunner.ts b/typescript/src/utils/interactiveRunner.ts new file mode 100644 index 0000000..a78c0ed --- /dev/null +++ b/typescript/src/utils/interactiveRunner.ts @@ -0,0 +1,210 @@ +/** + * @file Interactive script runner for ephemere project. + * @copyright 2025 Naomi Carrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { execSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { select } from "@inquirer/prompts"; + +const currentFilename = fileURLToPath(import.meta.url); +const currentDirectory = dirname(currentFilename); + +interface ScriptOption { + name: string; + value: string; + description?: string; +} + +const getTypeScriptCategories = (): Array => { + const sourcePath = join(currentDirectory, ".."); + const entries = readdirSync(sourcePath); + + return entries. + filter((entry) => { + const fullPath = join(sourcePath, entry); + const entryIsDirectory = statSync(fullPath).isDirectory(); + return entryIsDirectory && entry !== "utils" && entry !== "interfaces"; + }). + sort((a, b) => { + return a.localeCompare(b); + }); +}; + +const getTypeScriptScripts = (category: string): Array => { + const categoryPath = join(currentDirectory, "..", category); + const scripts: Array = []; + + const walkDirectory = (directory: string): void => { + const entries = readdirSync(directory); + + for (const entry of entries) { + const fullPath = join(directory, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + walkDirectory(fullPath); + } else if (entry.endsWith(".ts") && entry !== "index.ts") { + const relativePath = relative(join(currentDirectory, ".."), fullPath); + scripts.push({ + description: relativePath, + name: entry.replace(".ts", ""), + value: relativePath, + }); + } + } + }; + + walkDirectory(categoryPath); + return scripts.sort((a, b) => { + return a.name.localeCompare(b.name); + }); +}; + +const getPythonCategories = (): Array => { + const pythonPath = join(currentDirectory, "../../../../python"); + const entries = readdirSync(pythonPath); + + const categories = entries. + filter((entry) => { + const fullPath = join(pythonPath, entry); + const entryIsDirectory = statSync(fullPath).isDirectory(); + const isNotHidden = !entry.startsWith("."); + return entryIsDirectory && isNotHidden && entry !== "__pycache__"; + }). + sort((a, b) => { + return a.localeCompare(b); + }); + + // Also check for scripts in the root + const hasRootScripts = entries.some((entry) => { + return entry.endsWith(".py"); + }); + if (hasRootScripts) { + categories.unshift("(root)"); + } + + return categories; +}; + +const getPythonScripts = (category: string): Array => { + const pythonPath = join(currentDirectory, "../../../../python"); + const searchPath = category === "(root)" + ? pythonPath + : join(pythonPath, category); + + const scripts: Array = []; + const entries = readdirSync(searchPath); + + for (const entry of entries) { + if (entry.endsWith(".py") && !entry.startsWith("__")) { + const relativePath = category === "(root)" + ? entry + : join(category, entry); + scripts.push({ + description: relativePath, + name: entry.replace(".py", ""), + value: relativePath, + }); + } + } + + return scripts.sort((a, b) => { + return a.name.localeCompare(b.name); + }); +}; + +const selectLanguage = async(): Promise => { + return await select({ + choices: [ + { + description: "Run a TypeScript script", + name: "TypeScript", + value: "typescript", + }, + { + description: "Run a Python script", + name: "Python", + value: "python", + }, + ], + message: "Which language would you like to run?", + }); +}; + +const selectCategory = async(categories: Array): Promise => { + return await select({ + choices: categories.map((cat) => { + return { + name: cat === "(root)" + ? "Root Directory" + : cat.charAt(0).toUpperCase() + cat.slice(1), + value: cat, + }; + }), + message: "Which category?", + }); +}; + +const buildCommand = (language: string, script: string): string => { + const environmentPath = join(currentDirectory, "../../../../../prod.env"); + const typescriptDirectory = join(currentDirectory, "../../../"); + const pythonDirectory = join(currentDirectory, "../../../../python"); + + return language === "typescript" + ? `cd ${typescriptDirectory} && op run --env-file=${environmentPath} -- pnpm exec tsx src/${script}` + : `cd ${pythonDirectory} && op run --env-file=${environmentPath} -- uv run python ${script}`; +}; + +const executeScript = (script: string, command: string): void => { + console.log(`\n✨ Running: ${script}\n`); + + try { + execSync(command, { + shell: "/bin/bash", + stdio: "inherit", + }); + } catch { + console.error("\n❌ Script execution failed!"); + process.exit(1); + } +}; + +const main = async(): Promise => { + console.log("🌸 Welcome to Ephemere Script Runner! 💖\n"); + + const language = await selectLanguage(); + + const categories = language === "typescript" + ? getTypeScriptCategories() + : getPythonCategories(); + + if (categories.length === 0) { + console.error(`No categories found for ${language}!`); + process.exit(1); + } + + const category = await selectCategory(categories); + + const scripts = language === "typescript" + ? getTypeScriptScripts(category) + : getPythonScripts(category); + + if (scripts.length === 0) { + console.error(`No scripts found in ${category}!`); + process.exit(1); + } + + const script = await select({ + choices: scripts, + message: "Which script would you like to run?", + }); + + const command = buildCommand(language, script); + executeScript(script, command); +}; + +await main(); -- 2.52.0