Compare commits

..

2 Commits

Author SHA1 Message Date
naomi c829ec97c4 feat: testy 2026-02-02 21:15:34 -08:00
naomi dac875c413 feat: add adb scripts 2026-02-02 18:32:18 -08:00
73 changed files with 2769 additions and 2821 deletions
+6
View File
@@ -15,3 +15,9 @@ Thumbs.db
# IDE
.vscode/
.idea/
# Coverage
coverage/
.coverage
htmlcov/
*.lcov
-86
View File
@@ -1,86 +0,0 @@
# Documentation TODO
## Plan
Add a `README.md` to each script category folder. Each README should document every script in that folder with:
- What the script does (1-2 sentences)
- Data files required (filename, what it contains, where to put it - top-level `data/`)
- Environment variables required
## Categories to Document
### TypeScript
- `typescript/src/crowdin/README.md`
- `clearHiddenTranslations.ts`
- `reapplyTranslations.ts`
- `writeData.ts`
- `typescript/src/discord/README.md`
- `cycThreads.ts`
- `guildCount.ts`
- `typescript/src/discourse/README.md`
- `bulkUpdateCategories.ts`
- `closeOldTopics.ts`
- `typescript/src/gitea/README.md`
- `deleteFromAllRepos.ts`
- `uploadToAllRepos.ts`
- `uploadToReposConditionally.ts`
- `typescript/src/github/README.md`
- `auditNpmPackages.ts`
- `onboardMentee.ts`
- `postUserStories.ts`
- `typescript/src/music/README.md`
- `id3v2.ts`
- `typescript/src/s3/README.md`
- `bulkUpload.ts`
- `correctContentType.ts`
- `deleteContents.ts`
- `upload.ts`
- `typescript/src/security/README.md`
- `generateReport.ts`
### Python
- `python/cohort/README.md`
- `add_github_team_members.py`
- `analyse_availability.py`
- `assign_cohort_role.py`
- `assign_team_roles.py`
- `catch_up_report.py`
- `check_channel_permissions.py`
- `check_lengths.py`
- `check_member_status.py`
- `create_team_voice_channels.py`
- `discord_activity_checker.py`
- `evaluate_technical_proficiency.py`
- `fetch_roster.py`
- `fix_channel_permissions.py`
- `generate_member_files.py`
- `generate_timeslots.py`
- `get_cohort_members.py`
- `list_all_guild_roles.py`
- `list_discord_roles.py`
- `remove_discord_roles.py`
- `remove_member.py`
- `remove_resigned_members.py`
- `send_activity_report.py`
- `send_checkin.py`
- `send_team_checkin.py`
- `send_team_messages.py`
- `update_cohort_leads_permissions.py`
- `update_roster_messages.py`
- `verify_discord.py`
## Notes
- All data files go in the top-level `data/` directory
- Python scripts resolve `data/` via `DATA_DIR = Path(__file__).parent.parent.parent / "data"`
- TypeScript scripts resolve `data/` via `join(import.meta.dirname, "..", "..", "data")`
- Each README should have a quick "Getting Started" section explaining how to run scripts (via `run.sh` or the Makefile)
+42 -6
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test clean run
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test test-ts test-py coverage coverage-ts coverage-py clean run
# Default target - show help
help:
@@ -12,11 +12,17 @@ help:
@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 test - Run all tests (TypeScript and Python)"
@echo " make test-ts - Run TypeScript tests only"
@echo " make test-py - Run Python tests only"
@echo " make coverage - Run all tests with coverage"
@echo " make coverage-ts - Run TypeScript tests with coverage"
@echo " make coverage-py - Run Python tests with coverage"
@echo " make clean - Clean build artifacts and caches"
@echo ""
@echo "Running scripts:"
@echo " make run - Interactive script runner (select language, category, script)"
@echo " make run-bash - Run a bash script (e.g., make run-bash SCRIPT=bash/adb/push.sh)"
# Install all dependencies
install: install-ts install-py
@@ -60,10 +66,27 @@ 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
# Run all tests
test: test-ts test-py
# Run TypeScript tests
test-ts:
cd typescript && pnpm test
# Run Python tests
test-py:
cd python && uv run pytest -v
# Run all tests with coverage
coverage: coverage-ts coverage-py
# Run TypeScript tests with coverage
coverage-ts:
cd typescript && pnpm test:coverage
# Run Python tests with coverage
coverage-py:
cd python && uv run pytest --cov=. --cov-report=term-missing -v
# Clean build artifacts and caches
clean:
@@ -76,3 +99,16 @@ clean:
# Interactive script runner
run:
@./run.sh
# Run a specific bash script
run-bash:
@if [ -z "$(SCRIPT)" ]; then \
echo "Please specify a script: make run-bash SCRIPT=bash/adb/push.sh"; \
exit 1; \
fi
@if [ ! -f "$(SCRIPT)" ]; then \
echo "Script not found: $(SCRIPT)"; \
exit 1; \
fi
@echo "Running bash script: $(SCRIPT)"
@op run --env-file=prod.env --no-masking -- bash "$(SCRIPT)"
+150
View File
@@ -0,0 +1,150 @@
# ADB File Transfer Scripts
Easy-to-use bash scripts for transferring files between your computer and Android device using ADB.
## Prerequisites
- Android Debug Bridge (ADB) installed and in your PATH
- USB debugging enabled on your Android device
- Device connected via USB cable
### Installing ADB
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install android-tools-adb
```
**macOS:**
```bash
brew install android-platform-tools
```
**Windows:**
Download from [Android Developer website](https://developer.android.com/studio/releases/platform-tools)
## Scripts Overview
### 🚀 push.sh - Push files to Android
Transfer files from your computer to your Android device.
**Usage:**
```bash
# Interactive mode (recommended)
./push.sh
# Direct mode
./push.sh ~/Documents/file.pdf /sdcard/Download/
./push.sh ~/Pictures/vacation/ /sdcard/Pictures/
```
**Features:**
- Interactive mode with common destination suggestions
- Automatic path validation
- Directory creation if needed
- Progress feedback
- Support for both files and directories
### 📥 pull.sh - Pull files from Android
Transfer files from your Android device to your computer.
**Usage:**
```bash
# Interactive mode (recommended)
./pull.sh
# Direct mode
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/
./pull.sh /sdcard/Download/document.pdf ~/Downloads/
```
**Features:**
- Browse Android filesystem interactively
- Quick access to common folders
- File count and size summary
- Creates destination directories automatically
- Support for both files and directories
### 📱 adb-transfer.sh - All-in-One Menu
Interactive menu system for all file transfer operations.
**Usage:**
```bash
./adb-transfer.sh
```
**Features:**
- Device status and information display
- Quick actions (backup photos, screenshots, WhatsApp media)
- Access to push/pull scripts
- Device information viewer
- Beautiful CLI interface
## Common Android Paths
- `/sdcard/Download/` - Downloads folder
- `/sdcard/DCIM/Camera/` - Camera photos and videos
- `/sdcard/Pictures/` - General pictures folder
- `/sdcard/Screenshots/` - Screenshots (varies by device)
- `/sdcard/WhatsApp/Media/` - WhatsApp media files
- `/sdcard/Documents/` - Documents folder
- `/sdcard/Music/` - Music files
- `/sdcard/Movies/` - Video files
## Making Scripts Executable
First time setup:
```bash
chmod +x push.sh pull.sh adb-transfer.sh
```
## Examples
### Backup all camera photos
```bash
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/android-backup/
```
### Push multiple PDFs to Downloads
```bash
./push.sh ~/Documents/*.pdf /sdcard/Download/
```
### Interactive file browser
```bash
./pull.sh
# Then select option 1 to browse filesystem
```
### Quick backup using menu
```bash
./adb-transfer.sh
# Select option 3 for quick actions
# Select option 1 to backup all camera photos
```
## Troubleshooting
### "No device connected" error
1. Check USB cable connection
2. Enable USB debugging: Settings → Developer options → USB debugging
3. Accept the authorization prompt on your phone
4. Try `adb devices` to verify connection
### "Permission denied" errors
- Some system directories require root access
- Stick to `/sdcard/` paths for normal usage
### Slow transfer speeds
- Use USB 3.0 ports and cables when possible
- Large files/directories take time - the scripts show progress
## Tips
1. **Use interactive mode** - It's easier and prevents typos
2. **Backup regularly** - Use the quick actions menu for easy backups
3. **Check free space** - Use device info option to see available storage
4. **Organize transfers** - The scripts create timestamped folders for backups
## Created with 💕 by Naomi & Hikari
+225
View File
@@ -0,0 +1,225 @@
#!/bin/bash
# Interactive ADB file transfer menu
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# ASCII art banner
banner() {
echo -e "${CYAN}"
echo "╔═══════════════════════════════════╗"
echo "║ 📱 ADB File Transfer 📱 ║"
echo "║ Made with 💕 by Hikari ║"
echo "╚═══════════════════════════════════╝"
echo -e "${NC}"
}
# Check if ADB is available and device connected
check_adb_status() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ ADB not found${NC}"
return 1
fi
if adb devices | grep -q "device$"; then
local device=$(adb devices | grep "device$" | awk '{print $1}')
local model=$(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')
local android_version=$(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')
echo -e "${GREEN}✅ Device connected${NC}"
echo -e "${YELLOW}📱 Model: ${model:-Unknown}${NC}"
echo -e "${YELLOW}🤖 Android: ${android_version:-Unknown}${NC}"
echo -e "${YELLOW}🔌 ID: ${device}${NC}"
return 0
else
echo -e "${RED}❌ No device connected${NC}"
return 1
fi
}
# Quick actions menu
quick_actions() {
echo -e "\n${BLUE}⚡ Quick Actions${NC}"
echo "1) Pull all photos from camera"
echo "2) Pull all screenshots"
echo "3) Pull WhatsApp media"
echo "4) Push files to Downloads"
echo "5) Back to main menu"
echo ""
read -p "Select action (1-5): " action
case $action in
1)
# Pull camera photos
echo -e "${CYAN}📸 Pulling camera photos...${NC}"
local backup_dir="$HOME/Pictures/android-camera-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
if adb pull /sdcard/DCIM/Camera/ "$backup_dir/"; then
echo -e "${GREEN}✅ Photos backed up to: $backup_dir${NC}"
echo "Files: $(find "$backup_dir" -type f | wc -l)"
echo "Size: $(du -sh "$backup_dir" | cut -f1)"
else
echo -e "${RED}❌ Failed to pull photos${NC}"
fi
;;
2)
# Pull screenshots
echo -e "${CYAN}📸 Pulling screenshots...${NC}"
local screenshot_dir="$HOME/Pictures/android-screenshots-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$screenshot_dir"
# Try multiple possible screenshot locations
local found=false
for path in "/sdcard/Pictures/Screenshots" "/sdcard/Screenshots" "/sdcard/DCIM/Screenshots"; do
if adb shell "test -d '$path' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
if adb pull "$path" "$screenshot_dir/"; then
found=true
break
fi
fi
done
if [[ "$found" == true ]]; then
echo -e "${GREEN}✅ Screenshots backed up to: $screenshot_dir${NC}"
echo "Files: $(find "$screenshot_dir" -type f | wc -l)"
else
echo -e "${RED}❌ No screenshots found or failed to pull${NC}"
fi
;;
3)
# Pull WhatsApp media
echo -e "${CYAN}💬 Pulling WhatsApp media...${NC}"
local whatsapp_dir="$HOME/Pictures/whatsapp-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$whatsapp_dir"
if adb shell "test -d '/sdcard/WhatsApp/Media' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
if adb pull /sdcard/WhatsApp/Media/ "$whatsapp_dir/"; then
echo -e "${GREEN}✅ WhatsApp media backed up to: $whatsapp_dir${NC}"
echo "Files: $(find "$whatsapp_dir" -type f | wc -l)"
echo "Size: $(du -sh "$whatsapp_dir" | cut -f1)"
else
echo -e "${RED}❌ Failed to pull WhatsApp media${NC}"
fi
else
echo -e "${RED}❌ WhatsApp media folder not found${NC}"
fi
;;
4)
# Push to Downloads
echo -e "${CYAN}📤 Push files to Downloads folder${NC}"
read -p "Enter file/folder path to push: " -e local_path
local_path="${local_path/#\~/$HOME}"
if [[ -e "$local_path" ]]; then
if adb push "$local_path" /sdcard/Download/; then
echo -e "${GREEN}✅ Files pushed to Downloads folder${NC}"
else
echo -e "${RED}❌ Failed to push files${NC}"
fi
else
echo -e "${RED}❌ Path not found: $local_path${NC}"
fi
;;
5)
return
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
echo ""
read -p "Press Enter to continue..."
}
# Device info
device_info() {
echo -e "\n${BLUE}📱 Device Information${NC}"
echo "================================"
# Basic info
echo -e "${YELLOW}Model:${NC} $(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}Manufacturer:${NC} $(adb shell getprop ro.product.manufacturer 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}Android Version:${NC} $(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}SDK Version:${NC} $(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r\n')"
# Storage info
echo -e "\n${CYAN}Storage:${NC}"
adb shell df -h /sdcard | tail -n 1 | awk '{print " Used: " $3 " / " $2 " (" $5 ")"}'
# Battery info
echo -e "\n${CYAN}Battery:${NC}"
local battery_level=$(adb shell dumpsys battery | grep "level:" | awk '{print $2}')
local battery_status=$(adb shell dumpsys battery | grep "status:" | awk '{print $2}')
echo " Level: ${battery_level}%"
echo " Status: ${battery_status}"
echo ""
read -p "Press Enter to continue..."
}
# Main menu
main_menu() {
while true; do
clear
banner
# Check device status
echo -e "${MAGENTA}Device Status:${NC}"
if ! check_adb_status; then
echo -e "\n${YELLOW}Please connect your Android device and enable USB debugging${NC}"
echo ""
read -p "Press Enter to retry or Ctrl+C to exit..."
continue
fi
echo -e "\n${GREEN}Main Menu:${NC}"
echo "1) Push files to Android"
echo "2) Pull files from Android"
echo "3) Quick actions"
echo "4) Device information"
echo "5) Exit"
echo ""
read -p "Select option (1-5): " choice
case $choice in
1)
echo -e "\n${CYAN}Starting push mode...${NC}\n"
bash "$(dirname "$0")/push.sh"
;;
2)
echo -e "\n${CYAN}Starting pull mode...${NC}\n"
bash "$(dirname "$0")/pull.sh"
;;
3)
quick_actions
;;
4)
device_info
;;
5)
echo -e "${GREEN}👋 Goodbye!${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
}
# Run main menu
main_menu
+298
View File
@@ -0,0 +1,298 @@
#!/bin/bash
# Pull files from Android device via ADB
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 [android_source] [local_destination]${NC}"
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
echo ""
echo "Common Android paths:"
echo " /sdcard/Download/ - Downloads folder"
echo " /sdcard/DCIM/ - Camera folder"
echo " /sdcard/Pictures/ - Pictures folder"
echo " /sdcard/WhatsApp/ - WhatsApp media"
echo " /sdcard/Screenshots/ - Screenshots"
echo ""
echo "Examples:"
echo " $0 /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/"
echo " $0 /sdcard/Download/document.pdf ~/Downloads/"
echo " $0"
exit 1
}
# Function to check if ADB is installed and device is connected
check_adb() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
echo "Please install Android Debug Bridge (ADB) first"
exit 1
fi
# Check if device is connected
if ! adb devices | grep -q "device$"; then
echo -e "${RED}❌ Error: No Android device connected${NC}"
echo "Please connect your device and enable USB debugging"
echo ""
echo "Current devices:"
adb devices
exit 1
fi
}
# Function to browse Android filesystem
browse_android_fs() {
local current_path="${1:-/sdcard/}"
while true; do
echo -e "${CYAN}📱 Current path: $current_path${NC}"
echo ""
# List contents
echo -e "${YELLOW}Contents:${NC}"
local items=$(adb shell "ls -la '$current_path' 2>/dev/null" | tail -n +2 | awk '{print $NF}' | grep -v "^\.$" | grep -v "^\.\.$")
local i=1
local -a entries=()
# Add parent directory option if not at root
if [[ "$current_path" != "/" ]]; then
echo "0) .. (Go up)"
entries[0]=".."
fi
# List items
while IFS= read -r item; do
if [[ -n "$item" ]]; then
# Check if directory
if adb shell "test -d '$current_path/$item' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
echo "$i) $item/"
else
echo "$i) $item"
fi
entries[$i]="$item"
((i++))
fi
done <<< "$items"
echo ""
echo "s) Select this path"
echo "q) Quit"
echo ""
read -p "Enter choice: " choice
case $choice in
s|S)
echo "$current_path"
return 0
;;
q|Q)
return 1
;;
0)
if [[ "$current_path" != "/" ]]; then
current_path=$(dirname "$current_path")
fi
;;
[0-9]*)
if [[ -n "${entries[$choice]:-}" ]]; then
local selected="${entries[$choice]}"
local new_path="$current_path/$selected"
# Clean up path
new_path=$(echo "$new_path" | sed 's|//|/|g')
# Check if it's a directory
if adb shell "test -d '$new_path' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
current_path="$new_path"
else
# It's a file, return it
echo "$new_path"
return 0
fi
else
echo -e "${RED}Invalid choice${NC}"
fi
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
echo ""
done
}
# Interactive mode
interactive_mode() {
echo -e "${BLUE}📲 ADB Pull - Interactive Mode${NC}"
echo ""
# Choose method
echo -e "${YELLOW}How would you like to select the source?${NC}"
echo "1) Browse Android filesystem"
echo "2) Enter path directly"
echo "3) Quick access to common folders"
echo ""
read -p "Select method (1-3): " method
case $method in
1)
# Browse mode
if source_path=$(browse_android_fs); then
echo -e "${GREEN}Selected: $source_path${NC}"
else
echo -e "${RED}❌ Cancelled${NC}"
exit 1
fi
;;
2)
# Direct path entry
read -p "Enter Android source path: " -e source_path
;;
3)
# Quick access
echo ""
echo -e "${YELLOW}Common locations:${NC}"
echo "1) /sdcard/DCIM/Camera/"
echo "2) /sdcard/Pictures/"
echo "3) /sdcard/Download/"
echo "4) /sdcard/WhatsApp/Media/"
echo "5) /sdcard/Screenshots/"
echo "6) /sdcard/Documents/"
echo "7) /sdcard/Music/"
echo ""
read -p "Select location (1-7): " quick_choice
case $quick_choice in
1) source_path="/sdcard/DCIM/Camera/" ;;
2) source_path="/sdcard/Pictures/" ;;
3) source_path="/sdcard/Download/" ;;
4) source_path="/sdcard/WhatsApp/Media/" ;;
5) source_path="/sdcard/Screenshots/" ;;
6) source_path="/sdcard/Documents/" ;;
7) source_path="/sdcard/Music/" ;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
# Get destination
echo ""
read -p "Enter local destination path (default: current directory): " -e dest_path
dest_path="${dest_path:-.}"
dest_path="${dest_path/#\~/$HOME}"
# Pull the file/directory
pull_from_android "$source_path" "$dest_path"
}
# Function to pull files from Android
pull_from_android() {
local source="$1"
local dest="$2"
# Validate source exists
if ! adb shell "test -e '$source' 2>/dev/null && echo 'exists'" | grep -q "exists"; then
echo -e "${RED}❌ Error: Source '$source' does not exist on device${NC}"
exit 1
fi
# Create destination directory if needed
if [[ ! -d "$dest" ]]; then
mkdir -p "$dest"
fi
echo -e "${BLUE}📥 Pulling from Android device...${NC}"
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Check if source is directory
if adb shell "test -d '$source' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
echo -e "${YELLOW}Pulling directory...${NC}"
# Count files for progress
local file_count=$(adb shell "find '$source' -type f 2>/dev/null | wc -l" | tr -d '\r\n')
echo "Found $file_count files to pull"
echo ""
if adb pull "$source" "$dest"; then
echo -e "${GREEN}✅ Directory pulled successfully!${NC}"
# Show summary
local pulled_dir="$dest/$(basename "$source")"
if [[ -d "$pulled_dir" ]]; then
echo ""
echo -e "${BLUE}📊 Summary:${NC}"
echo "Location: $pulled_dir"
echo "Files: $(find "$pulled_dir" -type f | wc -l)"
echo "Total size: $(du -sh "$pulled_dir" | cut -f1)"
fi
else
echo -e "${RED}❌ Failed to pull directory${NC}"
exit 1
fi
else
# Single file
if adb pull "$source" "$dest"; then
echo -e "${GREEN}✅ File pulled successfully!${NC}"
# Show file info
local filename=$(basename "$source")
local full_path="$dest/$filename"
echo ""
echo -e "${BLUE}📄 File info:${NC}"
ls -lh "$full_path"
else
echo -e "${RED}❌ Failed to pull file${NC}"
exit 1
fi
fi
}
# Main script
main() {
check_adb
if [[ $# -eq 0 ]]; then
# No arguments, run interactive mode
interactive_mode
elif [[ $# -eq 1 ]]; then
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
else
echo -e "${RED}❌ Error: Missing destination path${NC}"
usage
fi
elif [[ $# -eq 2 ]]; then
# Arguments provided
dest_path="${2/#\~/$HOME}"
pull_from_android "$1" "$dest_path"
else
echo -e "${RED}❌ Error: Too many arguments${NC}"
usage
fi
}
main "$@"
+202
View File
@@ -0,0 +1,202 @@
#!/bin/bash
# Push files to Android device via ADB
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 [source_file/directory] [android_destination]${NC}"
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
echo ""
echo "Common Android paths:"
echo " /sdcard/Download/ - Downloads folder"
echo " /sdcard/DCIM/ - Camera folder"
echo " /sdcard/Pictures/ - Pictures folder"
echo " /sdcard/Documents/ - Documents folder"
echo " /sdcard/Music/ - Music folder"
echo ""
echo "Examples:"
echo " $0 photo.jpg /sdcard/Pictures/"
echo " $0 ~/Documents/file.pdf /sdcard/Download/"
echo " $0"
exit 1
}
# Function to check if ADB is installed and device is connected
check_adb() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
echo "Please install Android Debug Bridge (ADB) first"
exit 1
fi
# Check if device is connected
if ! adb devices | grep -q "device$"; then
echo -e "${RED}❌ Error: No Android device connected${NC}"
echo "Please connect your device and enable USB debugging"
echo ""
echo "Current devices:"
adb devices
exit 1
fi
}
# Function to validate Android path
validate_android_path() {
local path="$1"
# Check if path starts with /
if [[ ! "$path" =~ ^/ ]]; then
echo -e "${YELLOW}⚠️ Warning: Path doesn't start with /, prepending /sdcard/${NC}"
path="/sdcard/$path"
fi
# Check if destination exists (create if it's a directory)
if [[ "$path" =~ /$ ]]; then
adb shell "mkdir -p '$path' 2>/dev/null || true"
else
# Check if parent directory exists
local parent_dir=$(dirname "$path")
adb shell "mkdir -p '$parent_dir' 2>/dev/null || true"
fi
echo "$path"
}
# Interactive mode
interactive_mode() {
echo -e "${BLUE}🚀 ADB Push - Interactive Mode${NC}"
echo ""
# Get source file/directory
read -p "Enter source file/directory path: " -e source_path
# Expand tilde and validate source
source_path="${source_path/#\~/$HOME}"
if [[ ! -e "$source_path" ]]; then
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
exit 1
fi
# Show common destinations
echo ""
echo -e "${YELLOW}Common Android destinations:${NC}"
echo "1) /sdcard/Download/"
echo "2) /sdcard/Pictures/"
echo "3) /sdcard/DCIM/"
echo "4) /sdcard/Documents/"
echo "5) /sdcard/Music/"
echo "6) /sdcard/Movies/"
echo "7) Custom path"
echo ""
read -p "Select destination (1-7): " choice
case $choice in
1) dest_path="/sdcard/Download/" ;;
2) dest_path="/sdcard/Pictures/" ;;
3) dest_path="/sdcard/DCIM/" ;;
4) dest_path="/sdcard/Documents/" ;;
5) dest_path="/sdcard/Music/" ;;
6) dest_path="/sdcard/Movies/" ;;
7)
read -p "Enter custom destination path: " -e dest_path
;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
# Push the file/directory
push_to_android "$source_path" "$dest_path"
}
# Function to push files to Android
push_to_android() {
local source="$1"
local dest="$2"
# Validate destination path
dest=$(validate_android_path "$dest")
echo -e "${BLUE}📦 Pushing to Android device...${NC}"
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Check if source is directory
if [[ -d "$source" ]]; then
echo -e "${YELLOW}Pushing directory...${NC}"
# For directories, adb push handles recursion automatically
if adb push "$source" "$dest"; then
echo -e "${GREEN}✅ Directory pushed successfully!${NC}"
else
echo -e "${RED}❌ Failed to push directory${NC}"
exit 1
fi
else
# Single file
if adb push "$source" "$dest"; then
echo -e "${GREEN}✅ File pushed successfully!${NC}"
# Show file info on device
if [[ "$dest" =~ /$ ]]; then
# Destination is a directory
filename=$(basename "$source")
full_path="${dest}${filename}"
else
# Destination is a file
full_path="$dest"
fi
echo ""
echo -e "${BLUE}📱 File on device:${NC}"
adb shell "ls -lh '$full_path'" 2>/dev/null || true
else
echo -e "${RED}❌ Failed to push file${NC}"
exit 1
fi
fi
}
# Main script
main() {
check_adb
if [[ $# -eq 0 ]]; then
# No arguments, run interactive mode
interactive_mode
elif [[ $# -eq 1 ]]; then
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
else
echo -e "${RED}❌ Error: Missing destination path${NC}"
usage
fi
elif [[ $# -eq 2 ]]; then
# Arguments provided
source_path="${1/#\~/$HOME}"
if [[ ! -e "$source_path" ]]; then
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
exit 1
fi
push_to_android "$source_path" "$2"
else
echo -e "${RED}❌ Error: Too many arguments${NC}"
usage
fi
}
main "$@"
+4 -4
View File
@@ -7,15 +7,15 @@ CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
# Discord
DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_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://Environment Variables - Naomi/Amari/bot token"
# AWS
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
S3_ENDPOINT="op://Private/Hetzner/S3 Endpoint"
AWS_ACCESS_KEY_ID="op://Private/S3/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/S3/S3 Secret Access Key"
S3_ENDPOINT="op://Private/S3/S3 Endpoint"
# Gitea
GITEA_TOKEN="op://Private/Gitea/token"
+3 -17
View File
@@ -1,29 +1,15 @@
#!/usr/bin/env python3
"""Add GitHub users to their appropriate teams in the cohort GitHub organisation.
Uses the GitHub CLI to add each member to their corresponding team and, for
leaders, to the team's leaders sub-team.
Data files (place in data/):
- team_assignments.json Team rosters with leaders and participants per team
- discord_to_github.json Mapping of Discord IDs to GitHub usernames
Env vars:
- None (uses `gh` CLI for authentication)
"""
"""Add GitHub users to their appropriate teams in nhcarrigan-spring-2026-cohort org"""
import json
import subprocess
import time
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# Load team assignments and Discord to GitHub mappings
with open(DATA_DIR / "team_assignments.json") as f:
with open("team_assignments.json") as f:
teams = json.load(f)
with open(DATA_DIR / "discord_to_github.json") as f:
with open("discord_to_github.json") as f:
discord_to_github = json.load(f)
# Map team names to GitHub team slugs
+3 -23
View File
@@ -1,26 +1,6 @@
"""Analyse applicant availability from a markdown table and produce UTC block stats.
Reads a markdown table of availability responses and a Discord verification file,
then produces a JSON summary of coverage across morning/afternoon/evening UTC blocks
for each day of the week.
Data files (place in data/):
- table.md Markdown table of applicant availability responses
- discord_verification.json Discord ID verification results (from verify_discord.py)
Outputs (written to data/):
- availability_analysis.json UTC block distribution per applicant
Env vars:
- None
"""
import json
import re
from collections import defaultdict
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
@@ -119,7 +99,7 @@ def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict:
def parse_table_md() -> list[dict]:
"""Parse table.md and extract availability data"""
with open(DATA_DIR / "table.md") as f:
with open("table.md") as f:
content = f.read()
lines = content.strip().split("\n")
@@ -151,7 +131,7 @@ def parse_table_md() -> list[dict]:
def main():
with open(DATA_DIR / "discord_verification.json") as f:
with open("discord_verification.json") as f:
verification = json.load(f)
verified_ids = {v[0] for v in verification["verified"]}
@@ -187,7 +167,7 @@ def main():
}
)
with open(DATA_DIR / "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)
+1 -4
View File
@@ -6,12 +6,9 @@ Respects Discord rate limits with proper backoff and retry logic.
import json
import os
import time
from pathlib import Path
import requests
DATA_DIR = Path(__file__).parent.parent.parent / "data"
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
COHORT_ROLE_ID = "1464314780935258112"
@@ -51,7 +48,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) ->
def main():
with open(DATA_DIR / "team_assignments.json") as f:
with open("team_assignments.json") as f:
teams = json.load(f)
all_users = []
+1 -4
View File
@@ -6,12 +6,9 @@ Respects Discord rate limits with proper backoff and retry logic.
import json
import os
import time
from pathlib import Path
import requests
DATA_DIR = Path(__file__).parent.parent.parent / "data"
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
@@ -67,7 +64,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) ->
def main():
with open(DATA_DIR / "team_assignments.json") as f:
with open("team_assignments.json") as f:
teams = json.load(f)
print(f"Assigning team roles to {len(teams)} teams...")
-519
View File
@@ -1,519 +0,0 @@
#!/usr/bin/env python3
"""Catch-Up Activity Report.
Generates a markdown report of Discord and GitHub activity since Feb 15, 2026.
Covers Discord messages in team channels (+ threads) and GitHub activity
(PRs opened, issues opened, issue comments, PR comments, PR reviews, commits).
"""
import asyncio
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_API_BASE = "https://discord.com/api/v10"
GITHUB_API_BASE = "https://api.github.com"
GITHUB_ORG = "nhcarrigan-spring-2026-cohort"
CUTOFF = datetime(2026, 2, 15, 0, 0, 0, tzinfo=timezone.utc)
CUTOFF_ISO = CUTOFF.isoformat().replace("+00:00", "Z")
OUTPUT_FILE = "catch_up_report.md"
TEXT_CHANNEL_IDS: dict[str, str] = {
"Crimson Dahlia": "1464316744909852682",
"Rose Camellia": "1464316751268286611",
"Amber Wisteria": "1464316761410113641",
"Ivory Orchid": "1464316770889240730",
"Teal Iris": "1464316776459407448",
"Peach Gardenia": "1464316785040953543",
"Violet Carnation": "1464316805261824032",
"Azure Lotus": "1464316814455472139",
"Coral Sunflower": "1464316819711066263",
"Indigo Tulip": "1464316826384072925",
"Scarlet Hydrangea": "1464316839306985506",
"Mint Narcissus": "1464316844251807952",
"Sage Marigold": "1464316850669093040",
}
def team_repo_slug(team_name: str) -> str:
"""Convert a team name to its repository slug."""
return team_name.lower().replace(" ", "-")
def get_github_token() -> str:
"""Retrieve the GitHub token via the gh CLI."""
result = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True, check=True
)
return result.stdout.strip()
class ActivityCollector:
"""Collects Discord and GitHub activity for the catch-up report."""
def __init__(self, discord_token: str, github_token: str) -> None:
self.discord_headers = {
"Authorization": f"Bot {discord_token}",
"Content-Type": "application/json",
}
self.github_headers = {
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
self.session: aiohttp.ClientSession | None = None
async def __aenter__(self) -> "ActivityCollector":
self.session = aiohttp.ClientSession()
return self
async def __aexit__(
self, exc_type: object, exc_val: object, exc_tb: object
) -> None:
if self.session:
await self.session.close()
async def get_discord_username(self, user_id: str) -> str:
"""Fetch a Discord user's display name or username."""
url = f"{DISCORD_API_BASE}/users/{user_id}"
async with self.session.get(url, headers=self.discord_headers) as response:
if response.status == 429:
retry_after = float((await response.json()).get("retry_after", 1))
await asyncio.sleep(retry_after)
return await self.get_discord_username(user_id)
if response.status != 200:
return "*(unknown)*"
data = await response.json()
return data.get("global_name") or data.get("username") or "*(unknown)*"
async def _get_discord_thread_ids(self, channel_id: str) -> list[str]:
"""Return IDs of all active and archived threads in a channel."""
thread_ids: list[str] = []
url = f"{DISCORD_API_BASE}/channels/{channel_id}/threads/active"
async with self.session.get(url, headers=self.discord_headers) as response:
if response.status == 200:
data = await response.json()
thread_ids.extend(t["id"] for t in data.get("threads", []))
for archive_type in ("public", "private"):
url = (
f"{DISCORD_API_BASE}/channels/{channel_id}"
f"/threads/archived/{archive_type}"
)
async with self.session.get(url, headers=self.discord_headers) as response:
if response.status == 200:
data = await response.json()
thread_ids.extend(t["id"] for t in data.get("threads", []))
return thread_ids
async def _count_messages_in_channel(
self, channel_id: str, label: str = ""
) -> dict[str, int]:
"""Count messages per Discord user ID since CUTOFF."""
counts: dict[str, int] = {}
before_id: str | None = None
page = 0
while True:
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages?limit=100"
if before_id:
url += f"&before={before_id}"
async with self.session.get(url, headers=self.discord_headers) as response:
if response.status == 429:
retry_after = float((await response.json()).get("retry_after", 1))
print(f" [Discord] rate limited, waiting {retry_after:.1f}s...")
await asyncio.sleep(retry_after)
continue
if response.status != 200:
print(f" [Discord] channel {channel_id} → HTTP {response.status}")
break
messages: list[dict] = await response.json()
if not messages:
break
page += 1
prefix = f" ({label})" if label else ""
print(
f" [Discord]{prefix} page {page}{len(messages)} messages fetched", # noqa: E501
end="\r",
)
reached_cutoff = False
for message in messages:
ts = datetime.fromisoformat(
message["timestamp"].replace("Z", "+00:00")
)
if ts < CUTOFF:
reached_cutoff = True
break
if message["author"].get("bot", False):
continue
author_id = message["author"]["id"]
counts[author_id] = counts.get(author_id, 0) + 1
if reached_cutoff or len(messages) < 100:
print()
break
before_id = messages[-1]["id"]
await asyncio.sleep(0.5)
return counts
async def collect_discord_counts(
self, team_name: str, channel_id: str, member_ids: list[str]
) -> dict[str, int]:
"""Return message counts per member for a team's channel and threads."""
print(" [Discord] Scanning main channel...")
totals: dict[str, int] = await self._count_messages_in_channel(
channel_id, label="main"
)
thread_ids = await self._get_discord_thread_ids(channel_id)
total_threads = len(thread_ids)
for i, thread_id in enumerate(thread_ids, start=1):
print(f" [Discord] Scanning thread {i}/{total_threads}...")
thread_counts = await self._count_messages_in_channel(
thread_id, label=f"thread {i}/{total_threads}"
)
for user_id, count in thread_counts.items():
totals[user_id] = totals.get(user_id, 0) + count
await asyncio.sleep(0.3)
if total_threads == 0:
print(" [Discord] No threads found.")
return {member_id: totals.get(member_id, 0) for member_id in member_ids}
async def _github_get_all_pages(self, url: str, params: dict) -> list[dict]:
"""Fetch all pages from a paginated GitHub REST API endpoint."""
results: list[dict] = []
page = 1
while True:
paged_params = {**params, "per_page": 100, "page": page}
async with self.session.get(
url, headers=self.github_headers, params=paged_params
) as response:
if response.status in (404, 422):
break
if response.status == 403:
print(f" [GitHub] rate limited on {url}, waiting 60s...")
await asyncio.sleep(60)
continue
if response.status != 200:
print(f" [GitHub] {url} → HTTP {response.status}")
break
data: list[dict] = await response.json()
if not data:
break
results.extend(data)
if len(data) < 100:
break
page += 1
await asyncio.sleep(0.2)
return results
async def collect_github_counts(
self, team_name: str, github_usernames: list[str]
) -> dict[str, dict[str, int]]:
"""Return activity counts per member for a team's GitHub repository."""
repo_slug = team_repo_slug(team_name)
repo = f"{GITHUB_ORG}/{repo_slug}"
print(f" [GitHub] repo: {repo}")
counts: dict[str, dict[str, int]] = {
username: {
"prs_opened": 0,
"issues_opened": 0,
"issue_comments": 0,
"pr_comments": 0,
"pr_reviews": 0,
"commits": 0,
}
for username in github_usernames
if username
}
def resolve_username(login: str) -> str | None:
lower = login.lower()
for u in github_usernames:
if u and u.lower() == lower:
return u
return None
print(" [GitHub] Fetching PRs...")
prs = await self._github_get_all_pages(
f"{GITHUB_API_BASE}/repos/{repo}/pulls",
{"state": "all", "sort": "created", "direction": "desc"},
)
print(f" [GitHub] {len(prs)} PRs fetched — counting opens since cutoff...")
for pr in prs:
created_at = datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00"))
if created_at < CUTOFF:
break
login = pr["user"]["login"]
username = resolve_username(login)
if username:
counts[username]["prs_opened"] += 1
print(" [GitHub] Fetching issues...")
issues = await self._github_get_all_pages(
f"{GITHUB_API_BASE}/repos/{repo}/issues",
{
"state": "all",
"sort": "created",
"direction": "desc",
"since": CUTOFF_ISO,
},
)
print(f" [GitHub] {len(issues)} issues/PRs fetched — counting issue opens...")
for issue in issues:
if "pull_request" in issue:
continue
created_at = datetime.fromisoformat(
issue["created_at"].replace("Z", "+00:00")
)
if created_at < CUTOFF:
continue
login = issue["user"]["login"]
username = resolve_username(login)
if username:
counts[username]["issues_opened"] += 1
print(" [GitHub] Fetching issue comments...")
issue_comments = await self._github_get_all_pages(
f"{GITHUB_API_BASE}/repos/{repo}/issues/comments",
{"sort": "created", "direction": "desc", "since": CUTOFF_ISO},
)
print(f" [GitHub] {len(issue_comments)} issue comments fetched.")
for comment in issue_comments:
created_at = datetime.fromisoformat(
comment["created_at"].replace("Z", "+00:00")
)
if created_at < CUTOFF:
continue
login = comment["user"]["login"]
username = resolve_username(login)
if username:
counts[username]["issue_comments"] += 1
print(" [GitHub] Fetching PR review comments...")
pr_comments = await self._github_get_all_pages(
f"{GITHUB_API_BASE}/repos/{repo}/pulls/comments",
{"sort": "created", "direction": "desc", "since": CUTOFF_ISO},
)
print(f" [GitHub] {len(pr_comments)} PR review comments fetched.")
for comment in pr_comments:
created_at = datetime.fromisoformat(
comment["created_at"].replace("Z", "+00:00")
)
if created_at < CUTOFF:
continue
login = comment["user"]["login"]
username = resolve_username(login)
if username:
counts[username]["pr_comments"] += 1
all_pr_numbers = [pr["number"] for pr in prs]
total_prs = len(all_pr_numbers)
print(f" [GitHub] Fetching reviews for {total_prs} PRs...")
for i, pr_number in enumerate(all_pr_numbers, start=1):
print(f" [GitHub] PR reviews: {i}/{total_prs}", end="\r")
reviews = await self._github_get_all_pages(
f"{GITHUB_API_BASE}/repos/{repo}/pulls/{pr_number}/reviews",
{},
)
for review in reviews:
submitted_at_raw = review.get("submitted_at")
if not submitted_at_raw:
continue
submitted_at = datetime.fromisoformat(
submitted_at_raw.replace("Z", "+00:00")
)
if submitted_at < CUTOFF:
continue
login = review["user"]["login"]
username = resolve_username(login)
if username:
counts[username]["pr_reviews"] += 1
await asyncio.sleep(0.1)
if total_prs > 0:
print()
member_list = list(counts.keys())
total_members = len(member_list)
print(f" [GitHub] Fetching commits for {total_members} members...")
for i, username in enumerate(member_list, start=1):
print(f" [GitHub] Commits: {i}/{total_members} ({username})", end="\r")
commits = await self._github_get_all_pages(
f"{GITHUB_API_BASE}/repos/{repo}/commits",
{"author": username, "since": CUTOFF_ISO},
)
counts[username]["commits"] = len(commits)
await asyncio.sleep(0.2)
if total_members > 0:
print()
return counts
def build_report(
team_data: list[dict],
discord_to_github: dict[str, str],
discord_usernames: dict[str, str],
discord_results: dict[str, dict[str, int]],
github_results: dict[str, dict[str, dict[str, int]]],
) -> str:
"""Build the markdown activity report."""
lines = [
"# Catch-Up Activity Report",
"",
f"**Period:** 2026-02-15 00:00 UTC → "
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC",
"",
"## Activity by Team",
"",
"| Discord ID | Discord Username | GitHub Username | Team | "
"Discord Messages | PRs Opened | Issues Opened | Issue Comments | "
"PR Comments | PR Reviews | Commits |",
"|------------|-----------------|-----------------|------|"
"-----------------|------------|---------------|----------------|"
"-------------|------------|---------|",
]
for team in team_data:
team_name = team["name"]
if team_name == "Jade Jasmine":
continue
member_ids = team["leaders"] + team["participants"]
team_discord_counts = discord_results.get(team_name, {})
team_github_counts = github_results.get(team_name, {})
for member_id in member_ids:
github_username = discord_to_github.get(member_id, "")
discord_username = discord_usernames.get(member_id, "*(unknown)*")
discord_msg_count = team_discord_counts.get(member_id, 0)
if github_username:
gh = team_github_counts.get(github_username, {})
prs = gh.get("prs_opened", 0)
issues = gh.get("issues_opened", 0)
issue_comments = gh.get("issue_comments", 0)
pr_comments = gh.get("pr_comments", 0)
pr_reviews = gh.get("pr_reviews", 0)
commits = gh.get("commits", 0)
else:
prs = issues = issue_comments = pr_comments = pr_reviews = commits = (
"N/A"
)
lines.append(
f"| {member_id} | {discord_username} | {github_username or 'N/A'} "
f"| {team_name} | {discord_msg_count} | {prs} | {issues} "
f"| {issue_comments} | {pr_comments} | {pr_reviews} | {commits} |"
)
lines.append("")
lines.append(
f"*Generated at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC*"
)
return "\n".join(lines)
async def main() -> None:
"""Run the catch-up activity report."""
print("Loading data files...")
with open(DATA_DIR / "team_assignments.json") as f:
team_data: list[dict] = json.load(f)
with open(DATA_DIR / "discord_to_github.json") as f:
discord_to_github: dict[str, str] = json.load(f)
print("Getting GitHub token via gh CLI...")
github_token = get_github_token()
print(f"\nCollecting activity since {CUTOFF.isoformat()}...\n")
discord_results: dict[str, dict[str, int]] = {}
github_results: dict[str, dict[str, dict[str, int]]] = {}
discord_usernames: dict[str, str] = {}
async with ActivityCollector(DISCORD_BOT_TOKEN, github_token) as collector:
all_member_ids: list[str] = []
for team in team_data:
if team["name"] == "Jade Jasmine":
continue
all_member_ids.extend(team["leaders"] + team["participants"])
unique_member_ids = list(dict.fromkeys(all_member_ids))
total_members = len(unique_member_ids)
print(f"Fetching Discord usernames for {total_members} members...")
for i, member_id in enumerate(unique_member_ids, start=1):
if member_id not in discord_usernames:
print(f" username {i}/{total_members}...", end="\r")
discord_usernames[member_id] = await collector.get_discord_username(
member_id
)
await asyncio.sleep(0.3)
print(f" Done — {total_members} usernames fetched. ")
for team in team_data:
team_name = team["name"]
if team_name == "Jade Jasmine":
continue
print(f"\n=== {team_name} ===")
channel_id = TEXT_CHANNEL_IDS[team_name]
member_ids = team["leaders"] + team["participants"]
discord_results[team_name] = await collector.collect_discord_counts(
team_name, channel_id, member_ids
)
github_usernames_for_team = [
discord_to_github[mid]
for mid in member_ids
if mid in discord_to_github and discord_to_github[mid]
]
github_results[team_name] = await collector.collect_github_counts(
team_name, github_usernames_for_team
)
print("\nBuilding report...")
report = build_report(
team_data,
discord_to_github,
discord_usernames,
discord_results,
github_results,
)
with open(OUTPUT_FILE, "w") as f:
f.write(report)
print(f"\n✅ Report saved to {OUTPUT_FILE}")
if __name__ == "__main__":
asyncio.run(main())
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env python3
"""Check cohort-team-* channels for incorrect @everyone or @cohort permissions.
Find channels where @everyone or @cohort has Send Messages or
Send Messages in Threads enabled.
"""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
BASE_URL = "https://discord.com/api/v10"
GUILD_ID = "739845668582981683"
SEND_MESSAGES = 0x0000000000000800
SEND_MESSAGES_IN_THREADS = 0x0000004000000000
async def check_permissions() -> None:
"""Check all cohort-team-* channels for permission issues."""
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
async with aiohttp.ClientSession() as session:
print("Fetching channels...")
async with session.get(
f"{BASE_URL}/guilds/{GUILD_ID}/channels", headers=headers
) as resp:
if resp.status != 200:
error = await resp.text()
print(f"Error fetching channels: {resp.status} - {error}")
return
channels = await resp.json()
print("Fetching roles...")
async with session.get(
f"{BASE_URL}/guilds/{GUILD_ID}/roles", headers=headers
) as resp:
if resp.status != 200:
error = await resp.text()
print(f"Error fetching roles: {resp.status} - {error}")
return
roles = await resp.json()
everyone_role_id = GUILD_ID
cohort_role_id = None
for role in roles:
if "cohort" in role["name"].lower():
cohort_role_id = role["id"]
print(f"Found cohort role: {role['name']} ({role['id']})")
break
if not cohort_role_id:
print("Warning: Could not find @cohort role!")
cohort_channels = [
ch
for ch in channels
if ch["name"].startswith("cohort-team-") and ch["type"] == 0
]
print(f"\nFound {len(cohort_channels)} cohort-team-* channels\n")
problematic_channels = []
for channel in sorted(cohort_channels, key=lambda x: x["name"]):
channel_name = channel["name"]
channel_id = channel["id"]
permission_overwrites = channel.get("permission_overwrites", [])
everyone_perms = None
cohort_perms = None
for overwrite in permission_overwrites:
if overwrite["id"] == everyone_role_id:
everyone_perms = overwrite
elif cohort_role_id and overwrite["id"] == cohort_role_id:
cohort_perms = overwrite
issues = []
if everyone_perms:
deny = int(everyone_perms.get("deny", "0"))
allow = int(everyone_perms.get("allow", "0"))
if (allow & SEND_MESSAGES) or not (deny & SEND_MESSAGES):
issues.append("@everyone can send messages")
if (allow & SEND_MESSAGES_IN_THREADS) or not (
deny & SEND_MESSAGES_IN_THREADS
):
issues.append("@everyone can send messages in threads")
else:
issues.append(
"@everyone has no permission overwrite (inheriting server perms)"
)
if cohort_perms and cohort_role_id:
deny = int(cohort_perms.get("deny", "0"))
allow = int(cohort_perms.get("allow", "0"))
if (allow & SEND_MESSAGES) or not (deny & SEND_MESSAGES):
issues.append("@cohort can send messages")
if (allow & SEND_MESSAGES_IN_THREADS) or not (
deny & SEND_MESSAGES_IN_THREADS
):
issues.append("@cohort can send messages in threads")
elif cohort_role_id:
issues.append(
"@cohort has no permission overwrite (inheriting server perms)"
)
if issues:
problematic_channels.append(
{"name": channel_name, "id": channel_id, "issues": issues}
)
print(f"{channel_name}")
for issue in issues:
print(f" - {issue}")
else:
print(f"{channel_name}")
print("\n" + "=" * 60)
print(
f"\nSummary: {len(problematic_channels)} channels with permission issues\n"
)
if problematic_channels:
print("Problematic channels:")
for ch in problematic_channels:
print(f"\n{ch['name']} (ID: {ch['id']})")
for issue in ch["issues"]:
print(f"{issue}")
else:
print("All channels have correct permissions! 🎉")
if __name__ == "__main__":
asyncio.run(check_permissions())
-86
View File
@@ -1,86 +0,0 @@
"""Dry-run check of Discord message lengths before sending the activity report.
Parses the catch_up_report.md table, formats each team's data into a monospace
Discord table, and reports whether any message would exceed Discord's 2000-char limit.
Run this before send_activity_report.py to catch length issues early.
Data files (place in data/):
- catch_up_report.md Activity report generated by catch_up_report.py
Env vars:
- None
"""
FIELDS = [
("Discord Username", "Name", 18),
("Discord Messages", "Msgs", 5),
("PRs Opened", "PRs", 4),
("Issues Opened", "Issues", 6),
("Issue Comments", "Issue♟", 7),
("PR Comments", "PR♟", 5),
("PR Reviews", "Reviews", 7),
("Commits", "Commits", 7),
]
REPORT_PATH = "data/catch_up_report.md"
def parse_report(path: str) -> dict[str, list[dict]]:
"""Parse the markdown table from catch_up_report.md into team → rows."""
teams: dict[str, list[dict]] = {}
with open(path, encoding="utf-8") as f:
lines = f.readlines()
header_line = None
for i, line in enumerate(lines):
if line.startswith("| Discord ID |"):
header_line = i
break
headers = [h.strip() for h in lines[header_line].strip().strip("|").split("|")]
for line in lines[header_line + 2 :]:
line = line.strip()
if not line.startswith("|"):
break
vals = [v.strip() for v in line.strip().strip("|").split("|")]
row = dict(zip(headers, vals))
teams.setdefault(row["Team"], []).append(row)
return teams
def format_table(members: list[dict]) -> str:
"""Format a team's member list as a monospace table for Discord."""
members = sorted(members, key=lambda r: int(r["Discord Messages"]), reverse=True)
col_widths = [w for _, _, w in FIELDS]
col_headers = [h for _, h, _ in FIELDS]
max_name = max(len(m["Discord Username"]) for m in members)
col_widths[0] = max(col_widths[0], max_name)
def pad(val: str, width: int, right_align: bool = False) -> str:
return val.rjust(width) if right_align else val.ljust(width)
header_row = " ".join(
pad(col_headers[i], col_widths[i], right_align=(i > 0))
for i in range(len(FIELDS))
)
separator = " ".join("-" * w for w in col_widths)
rows = []
for m in members:
vals = [m[key] for key, _, _ in FIELDS]
row = " ".join(
pad(vals[i], col_widths[i], right_align=(i > 0)) for i in range(len(FIELDS))
)
rows.append(row)
return "\n".join([header_row, separator] + rows)
def main() -> None:
"""Check Discord message lengths for all teams."""
teams = parse_report(REPORT_PATH)
for team, members in teams.items():
table = format_table(members)
msg = f"**{team} — Activity Report (Feb 1523)**\n```\n{table}\n```"
status = "OK" if len(msg) <= 2000 else f"OVER by {len(msg) - 2000}"
print(f"{team}: {len(msg)} chars — {status}")
if __name__ == "__main__":
main()
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Check if removed members are still in the Discord server."""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
BASE_URL = "https://discord.com/api/v10"
GUILD_ID = "1354624415861833870"
SAMPLE_MEMBERS = [
"899092786802987069",
"1318882254365397032",
"799293680799711273",
"237793557992308736",
]
async def check_member(session: aiohttp.ClientSession, user_id: str) -> bool | None:
"""Check if a member is in the server."""
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}"
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
roles = data.get("roles", [])
print(f"✅ User {user_id} IS in server - has {len(roles)} roles: {roles}")
return True
if resp.status == 404:
print(f"❌ User {user_id} NOT in server (left or was kicked)")
return False
error = await resp.text()
print(f"⚠️ Error checking {user_id}: {resp.status} - {error}")
return None
async def main() -> None:
"""Check if sample members are still in the server."""
async with aiohttp.ClientSession() as session:
for user_id in SAMPLE_MEMBERS:
await check_member(session, user_id)
await asyncio.sleep(1)
if __name__ == "__main__":
asyncio.run(main())
+4 -18
View File
@@ -1,17 +1,6 @@
#!/usr/bin/env python3
"""Check for team members who have not sent a message in their channel within 36 hours.
Scans each team's Discord channel and flags members with no recent activity.
Optionally sends a direct mention message to inactive members.
Data files (place in data/):
- team_assignments.json Team rosters with leaders and participants per team
Outputs (written to data/):
- discord_activity_report.json Inactive members per team with last-seen timestamps
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""Discord Team Activity Checker
Checks for team members who haven't sent messages in their channels within 36 hours
"""
import asyncio
@@ -19,19 +8,16 @@ import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# Configuration
DISCORD_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_API_BASE = "https://discord.com/api/v10"
INACTIVE_THRESHOLD_HOURS = 36
# Load team assignments from file
with open(DATA_DIR / "team_assignments.json") as f:
with open("team_assignments.json") as f:
team_data = json.load(f)
# Build TEAMS dictionary with channel IDs and member lists
@@ -247,7 +233,7 @@ async def main():
print("\n" + "=" * 80)
# Save results to JSON
with open(DATA_DIR / "discord_activity_report.json", "w") as f:
with open("discord_activity_report.json", "w") as f:
json.dump(
{
"generated_at": datetime.now(timezone.utc).isoformat(),
@@ -1,27 +1,8 @@
"""Evaluate the technical proficiency of cohort applicants using their GitHub profiles.
Fetches each applicant's public GitHub repositories and scores their proficiency as
Beginner, Intermediate, or Advanced based on language variety, repo count, commit
activity, and presence of certain technologies.
Data files (place in data/):
- applicants_to_evaluate.json List of applicants with GitHub usernames
Outputs (written to data/):
- proficiency_evaluations.json Proficiency scores and tech stacks per applicant
Env vars:
- None (uses public GitHub API; may be rate-limited without authentication)
"""
import json
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# GitHub API (no auth needed for public repos, but rate limited)
GITHUB_API = "https://api.github.com"
@@ -253,7 +234,7 @@ def evaluate_applicant(applicant: dict, index: int, total: int) -> dict:
def main():
# Load applicants
with open(DATA_DIR / "applicants_to_evaluate.json") as f:
with open("applicants_to_evaluate.json") as f:
applicants = json.load(f)
print(f"Evaluating {len(applicants)} applicants...\n")
@@ -268,7 +249,7 @@ def main():
print(f" Progress: {i + 1}/{len(applicants)} complete")
# Save results
with open(DATA_DIR / "proficiency_evaluations.json", "w") as f:
with open("proficiency_evaluations.json", "w") as f:
json.dump(evaluations, f, indent=2)
# Summary
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env python3
"""Fetch pinned messages from Ivory Orchid channel to find the roster."""
import asyncio
import json
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
CHANNEL_ID = "1464316770889240730"
async def main() -> None:
"""Fetch and print pinned messages from the Ivory Orchid channel."""
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
async with (
aiohttp.ClientSession() as session,
session.get(
f"https://discord.com/api/v10/channels/{CHANNEL_ID}/pins",
headers=headers,
) as response,
):
pins = await response.json()
print(json.dumps(pins, indent=2))
if __name__ == "__main__":
asyncio.run(main())
-129
View File
@@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""Fix Send Messages / Send Messages in Threads permissions for a Discord channel.
Denies Send Messages and Send Messages in Threads for both @everyone and the
@cohort role on the target channel. Update CHANNEL_ID and COHORT_ROLE_ID before
running.
Data files (place in data/):
- None
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
BASE_URL = "https://discord.com/api/v10"
GUILD_ID = "739845668582981683"
CHANNEL_ID = "1467964405646885237"
SEND_MESSAGES = 0x0000000000000800
SEND_MESSAGES_IN_THREADS = 0x0000004000000000
EVERYONE_ROLE_ID = GUILD_ID
COHORT_ROLE_ID = "1390925253102010521"
async def fix_permissions() -> None:
"""Fix the channel permissions."""
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession() as session:
print("Fetching current channel permissions...")
async with session.get(
f"{BASE_URL}/channels/{CHANNEL_ID}", headers=headers
) as resp:
if resp.status != 200:
error = await resp.text()
print(f"Error fetching channel: {resp.status} - {error}")
return
channel = await resp.json()
print(f"Channel: {channel['name']}")
permission_overwrites = channel.get("permission_overwrites", [])
everyone_overwrite = None
cohort_overwrite = None
for overwrite in permission_overwrites:
if overwrite["id"] == EVERYONE_ROLE_ID:
everyone_overwrite = overwrite
elif overwrite["id"] == COHORT_ROLE_ID:
cohort_overwrite = overwrite
print("\nFixing @everyone permissions...")
if everyone_overwrite:
current_deny = int(everyone_overwrite.get("deny", "0"))
current_allow = int(everyone_overwrite.get("allow", "0"))
new_deny = current_deny | SEND_MESSAGES | SEND_MESSAGES_IN_THREADS
new_allow = current_allow & ~SEND_MESSAGES & ~SEND_MESSAGES_IN_THREADS
payload = {
"type": 0,
"deny": str(new_deny),
"allow": str(new_allow),
}
else:
payload = {
"type": 0,
"deny": str(SEND_MESSAGES | SEND_MESSAGES_IN_THREADS),
"allow": "0",
}
async with session.put(
f"{BASE_URL}/channels/{CHANNEL_ID}/permissions/{EVERYONE_ROLE_ID}",
headers=headers,
json=payload,
) as resp:
if resp.status == 204:
print("✅ @everyone permissions fixed!")
else:
error = await resp.text()
print(f"❌ Error fixing @everyone: {resp.status} - {error}")
print("\nFixing @cohort permissions...")
if cohort_overwrite:
current_deny = int(cohort_overwrite.get("deny", "0"))
current_allow = int(cohort_overwrite.get("allow", "0"))
new_deny = current_deny | SEND_MESSAGES | SEND_MESSAGES_IN_THREADS
new_allow = current_allow & ~SEND_MESSAGES & ~SEND_MESSAGES_IN_THREADS
payload = {
"type": 0,
"deny": str(new_deny),
"allow": str(new_allow),
}
else:
payload = {
"type": 0,
"deny": str(SEND_MESSAGES | SEND_MESSAGES_IN_THREADS),
"allow": "0",
}
async with session.put(
f"{BASE_URL}/channels/{CHANNEL_ID}/permissions/{COHORT_ROLE_ID}",
headers=headers,
json=payload,
) as resp:
if resp.status == 204:
print("✅ @cohort permissions fixed!")
else:
error = await resp.text()
print(f"❌ Error fixing @cohort: {resp.status} - {error}")
print("\n✨ Done! Permissions have been fixed.")
if __name__ == "__main__":
asyncio.run(fix_permissions())
+7 -30
View File
@@ -1,46 +1,23 @@
"""Generate markdown participant and leader profile files for the cohort.
Reads all evaluation data files and produces two markdown files summarising
each member's tech stack, availability, proficiency, and leadership assessment.
Data files (place in data/):
- discord_verification.json Discord ID verification results (from verify_discord.py)
- proficiency_evaluations.json Proficiency scores (from evaluate_technical_proficiency.py)
- availability_analysis.json Availability UTC blocks (from analyse_availability.py)
- leadership_candidates.json List of applicants who expressed interest in leading
- leadership_evaluations.json Leadership assessment scores
Outputs (written to data/):
- participants.md Markdown profile for each participant
- leaders.md Markdown profile for each leader candidate
Env vars:
- None
"""
import json
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
BLOCK_EMOJIS = {"mornings": "🌅", "afternoons": "☀️", "evenings": "🌆", "nights": "🌙"}
def load_all_data():
"""Load all evaluation data files"""
with open(DATA_DIR / "discord_verification.json") as f:
with open("discord_verification.json") as f:
verification = json.load(f)
with open(DATA_DIR / "proficiency_evaluations.json") as f:
with open("proficiency_evaluations.json") as f:
proficiency = json.load(f)
with open(DATA_DIR / "availability_analysis.json") as f:
with open("availability_analysis.json") as f:
availability = json.load(f)
with open(DATA_DIR / "leadership_candidates.json") as f:
with open("leadership_candidates.json") as f:
candidates = json.load(f)
with open(DATA_DIR / "leadership_evaluations.json") as f:
with open("leadership_evaluations.json") as f:
leadership = json.load(f)
return verification, proficiency, availability, candidates, leadership
@@ -253,14 +230,14 @@ def main():
participants_md = generate_participants_md(
non_leader_ids, verified_usernames, prof_by_id, avail_by_id
)
with open(DATA_DIR / "participants.md", "w") as f:
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(DATA_DIR / "leaders.md", "w") as f:
with open("leaders.md", "w") as f:
f.write(leaders_md)
print(f"Generated leaders.md with {len(leader_ids)} leaders")
+1 -16
View File
@@ -1,20 +1,5 @@
"""Generate hourly timeslot JSON for use with Crabfit scheduling tool.
Produces a list of ISO-format datetime strings covering every hour across the
scheduling window. Update the start_date and end_date constants before running.
Outputs (written to data/):
- crabfit_timeslots.json List of hourly timeslot strings
Env vars:
- None
"""
import json
from datetime import datetime, timedelta
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# Generate hourly time slots from Feb 1 to March 3, 2026
# 24 hours a day, America/Los_Angeles timezone
@@ -33,7 +18,7 @@ print(f"First: {times[0]}")
print(f"Last: {times[-1]}")
# Save to file for use
with open(DATA_DIR / "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")
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env python3
"""Get all members who currently have the Cohort role."""
import asyncio
import json
import os
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
COHORT_ROLE_ID = "1464314780935258112"
async def get_all_members_with_role(
session: aiohttp.ClientSession,
) -> list[dict[str, str]]:
"""Get all members who have the Cohort role."""
members = []
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
after = None
while True:
url = f"https://discord.com/api/v10/guilds/{GUILD_ID}/members"
params: dict[str, str | int] = {"limit": 1000}
if after:
params["after"] = after
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
error_text = await resp.text()
raise RuntimeError(
f"Failed to fetch members: {resp.status} - {error_text}"
)
data = await resp.json()
if not data:
break
for member in data:
if COHORT_ROLE_ID in member.get("roles", []):
members.append(
{
"id": member["user"]["id"],
"username": member["user"]["username"],
"display_name": member.get("nick")
or member["user"]["username"],
}
)
if len(data) < 1000:
break
after = data[-1]["user"]["id"]
return members
async def main() -> None:
"""Get all cohort members with the Cohort role."""
async with aiohttp.ClientSession() as session:
print("Fetching all members with Cohort role...")
cohort_members = await get_all_members_with_role(session)
print(f"\n✨ Found {len(cohort_members)} members with the Cohort role:\n")
for i, member in enumerate(cohort_members, 1):
print(
f"{i}. {member['display_name']} (@{member['username']}) - ID: {member['id']}" # noqa: E501
)
with open(DATA_DIR / "active_cohort_members.json", "w") as f:
json.dump(cohort_members, f, indent=2)
print("\nSaved to active_cohort_members.json")
print("\nMember IDs only:")
print(json.dumps([m["id"] for m in cohort_members], indent=2))
if __name__ == "__main__":
asyncio.run(main())
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""List all roles in the guild to find the correct team role IDs."""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
BASE_URL = "https://discord.com/api/v10"
GUILD_ID = "1354624415861833870"
async def list_guild_roles() -> None:
"""List all roles in the guild, highlighting team-related ones."""
url = f"{BASE_URL}/guilds/{GUILD_ID}/roles"
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
async with (
aiohttp.ClientSession() as session,
session.get(url, headers=headers) as resp,
):
if resp.status == 200:
roles = await resp.json()
print(f"Found {len(roles)} roles in the server:\n")
team_names = [
"Jade Jasmine",
"Crimson Dahlia",
"Rose Camellia",
"Amber Wisteria",
"Ivory Orchid",
"Teal Iris",
"Peach Gardenia",
"Violet Carnation",
"Azure Lotus",
"Coral Sunflower",
"Indigo Tulip",
"Scarlet Hydrangea",
"Mint Narcissus",
"Sage Marigold",
"Cohort",
]
for role in sorted(roles, key=lambda r: r["name"].lower()):
name = role["name"]
is_team = any(team in name for team in team_names)
if is_team or "spring" in name.lower() or "2026" in name:
print(f"{role['id']}: {name}")
print("\n\nAll roles (sorted by name):")
for role in sorted(roles, key=lambda r: r["name"].lower()):
print(f"{role['id']}: {role['name']}")
else:
error = await resp.text()
print(f"Error: {resp.status} - {error}")
if __name__ == "__main__":
asyncio.run(list_guild_roles())
-48
View File
@@ -1,48 +0,0 @@
"""List all Discord roles in the freeCodeCamp server."""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
API_BASE = "https://discord.com/api/v10"
async def get_guild_roles(session: aiohttp.ClientSession) -> list[dict]:
"""Get all guild roles."""
url = f"{API_BASE}/guilds/{GUILD_ID}/roles"
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
return await resp.json()
print(f"Failed to get roles: {resp.status}")
text = await resp.text()
print(text)
return []
async def main() -> None:
"""List all roles from freeCodeCamp Discord, highlighting cohort-related ones."""
async with aiohttp.ClientSession() as session:
print("Fetching all roles from freeCodeCamp Discord...\n")
roles = await get_guild_roles(session)
print(f"Found {len(roles)} roles:\n")
cohort_roles = [
r
for r in roles
if "2026" in r["name"] or "Cohort" in r["name"] or "Leader" in r["name"]
]
print("=== Cohort/Leader Roles ===")
for role in cohort_roles:
print(f" {role['name']}: {role['id']}")
if __name__ == "__main__":
asyncio.run(main())
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""Remove the Cohort and team-specific Discord roles from a list of members.
Update INACTIVE_MEMBERS and MEMBER_TO_TEAM before running to target the correct
members. Removes both the cohort-wide role and the member's team role.
Data files (place in data/):
- None (member IDs and team mappings are defined as constants in the script)
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "1354624415861833870"
BASE_URL = "https://discord.com/api/v10"
INACTIVE_MEMBERS = [
"1177481351490981889",
"899092786802987069",
"1318882254365397032",
"680429718511943770",
"1195902319976521748",
"1424001797072359576",
"1333378962289590365",
"799293680799711273",
"1183395404293869662",
"1325958873831440566",
"717028253633871965",
"847789364217905182",
"746703502369095700",
"192541018908786690",
"1017761694514163712",
]
COHORT_ROLE_ID = "1464314780935258112"
TEAM_ROLE_IDS = {
1: "1464314923780931677",
2: "1464315093402784015",
3: "1464315098452726106",
4: "1464315105264275600",
5: "1464315109873684593",
6: "1464315114378498152",
7: "1464315118904152107",
8: "1464315124251754559",
9: "1464315128437801177",
10: "1464315132896088168",
11: "1464315138428633241",
12: "1464315142710890520",
13: "1464315149203804405",
14: "1464315153599299803",
}
MEMBER_TO_TEAM = {
"1177481351490981889": 1,
"899092786802987069": 1,
"1318882254365397032": 2,
"680429718511943770": 2,
"1195902319976521748": 3,
"1424001797072359576": 4,
"1333378962289590365": 4,
"799293680799711273": 5,
"1183395404293869662": 7,
"1325958873831440566": 8,
"717028253633871965": 8,
"847789364217905182": 10,
"746703502369095700": 10,
"192541018908786690": 11,
"1017761694514163712": 13,
}
async def remove_role(
session: aiohttp.ClientSession, user_id: str, role_id: str
) -> bool:
"""Remove a role from a user."""
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}"
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
async with session.delete(url, headers=headers) as resp:
if resp.status == 204:
return True
text = await resp.text()
print(f" Error removing role {role_id} from {user_id}: {resp.status} - {text}")
return False
async def main() -> None:
"""Remove roles from all inactive members."""
async with aiohttp.ClientSession() as session:
print("Removing roles from inactive members...\n")
for member_id in INACTIVE_MEMBERS:
print(f"Processing {member_id}...")
if await remove_role(session, member_id, COHORT_ROLE_ID):
print(" ✓ Removed cohort role")
if member_id in MEMBER_TO_TEAM:
team_id = MEMBER_TO_TEAM[member_id]
team_role_id = TEAM_ROLE_IDS[team_id]
if await remove_role(session, member_id, team_role_id):
print(f" ✓ Removed Team {team_id} role")
await asyncio.sleep(1.5)
print("\nDone!")
if __name__ == "__main__":
asyncio.run(main())
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bash
# Script to remove inactive members from GitHub organization teams
# Date: 2026-02-12
ORG="nhcarrigan-spring-2026-cohort"
# Team 1 (Jade Jasmine) - Remove leader and participant
echo "Removing from Jade Jasmine..."
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine-leaders/memberships/Mista-Log" || true
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/A-normal-programmer" || true
# Team 2 (Crimson Dahlia) - Remove 2 participants
echo "Removing from Crimson Dahlia..."
gh api --method DELETE "/orgs/$ORG/teams/crimson-dahlia/memberships/1s-crypto" || true
gh api --method DELETE "/orgs/$ORG/teams/crimson-dahlia/memberships/emlanis" || true
# Team 3 (Rose Camellia) - Remove leader
echo "Removing from Rose Camellia..."
gh api --method DELETE "/orgs/$ORG/teams/rose-camellia-leaders/memberships/michaelboateng1" || true
# Team 4 (Amber Wisteria) - Remove leader and participant
echo "Removing from Amber Wisteria..."
gh api --method DELETE "/orgs/$ORG/teams/amber-wisteria-leaders/memberships/neonbit101" || true
gh api --method DELETE "/orgs/$ORG/teams/amber-wisteria/memberships/avanishchandra" || true
# Team 5 (Ivory Orchid) - Remove participant
echo "Removing from Ivory Orchid..."
gh api --method DELETE "/orgs/$ORG/teams/ivory-orchid/memberships/VuBui217" || true
# Team 7 (Peach Gardenia) - Remove participant
echo "Removing from Peach Gardenia..."
gh api --method DELETE "/orgs/$ORG/teams/peach-gardenia/memberships/TabsOO7" || true
# Team 8 (Violet Carnation) - Remove 2 participants
echo "Removing from Violet Carnation..."
gh api --method DELETE "/orgs/$ORG/teams/violet-carnation/memberships/masudulalam" || true
gh api --method DELETE "/orgs/$ORG/teams/violet-carnation/memberships/urmilbhatt" || true
# Team 10 (Coral Sunflower) - Remove leader and participant
echo "Removing from Coral Sunflower..."
gh api --method DELETE "/orgs/$ORG/teams/coral-sunflower-leaders/memberships/AjayTheWizard" || true
gh api --method DELETE "/orgs/$ORG/teams/coral-sunflower/memberships/Hritikhh" || true
# Team 11 (Indigo Tulip) - Remove participant
echo "Removing from Indigo Tulip..."
gh api --method DELETE "/orgs/$ORG/teams/indigo-tulip/memberships/SiAust" || true
# Team 13 (Mint Narcissus) - Remove participant
echo "Removing from Mint Narcissus..."
gh api --method DELETE "/orgs/$ORG/teams/mint-narcissus/memberships/SergioPardoSanchez" || true
echo "Done removing members from GitHub teams!"
-247
View File
@@ -1,247 +0,0 @@
#!/usr/bin/env python3
"""Remove a member from the Spring 2026 Cohort.
This script:
1. Removes from team_assignments.json (so activity checker stops tracking them)
2. Removes from discord_to_github.json
3. Removes from GitHub teams
4. Removes Discord roles
5. Sends a message to the team channel announcing the removal
6. Outputs notes to add to COHORT_NOTES.md
Usage:
python remove_member.py <discord_id>
"""
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_API_BASE = "https://discord.com/api/v10"
DISCORD_GUILD_ID = "692816967895220344"
COHORT_ROLE_ID = "1464316447591985194"
TEXT_CHANNEL_IDS = {
"Jade Jasmine": "1464316501573107886",
"Crimson Dahlia": "1464316744909852682",
"Rose Camellia": "1464316751268286611",
"Amber Wisteria": "1464316761410113641",
"Ivory Orchid": "1464316770889240730",
"Teal Iris": "1464316776459407448",
"Peach Gardenia": "1464316785040953543",
"Violet Carnation": "1464316805261824032",
"Azure Lotus": "1464316814455472139",
"Coral Sunflower": "1464316819711066263",
"Indigo Tulip": "1464316826384072925",
"Scarlet Hydrangea": "1464316839306985506",
"Mint Narcissus": "1464316844251807952",
"Sage Marigold": "1464316850669093040",
}
class MemberRemover:
"""Handles the complete removal of a cohort member."""
def __init__(self, discord_id: str) -> None:
self.discord_id = discord_id
self.headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
self.session: aiohttp.ClientSession | None = None
self.github_username: str | None = None
self.team_name: str | None = None
self.role: str | None = None
self.team_role_id: str | None = None
async def __aenter__(self) -> "MemberRemover":
self.session = aiohttp.ClientSession()
return self
async def __aexit__(
self, exc_type: object, exc_val: object, exc_tb: object
) -> None:
if self.session:
await self.session.close()
def find_member_info(self) -> bool:
"""Find which team the member is on and their role."""
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
for team in teams:
if self.discord_id in team["leaders"]:
self.team_name = team["name"]
self.role = "leader"
return True
if self.discord_id in team["participants"]:
self.team_name = team["name"]
self.role = "participant"
return True
return False
def remove_from_team_assignments(self) -> None:
"""Remove member from team_assignments.json."""
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
for team in teams:
if self.discord_id in team["leaders"]:
team["leaders"].remove(self.discord_id)
print(
f"✅ Removed from {team['name']} leaders in team_assignments.json"
)
elif self.discord_id in team["participants"]:
team["participants"].remove(self.discord_id)
print(
f"✅ Removed from {team['name']} participants in team_assignments.json"
)
with open(DATA_DIR / "team_assignments.json", "w") as f:
json.dump(teams, f, indent=2)
def remove_from_discord_to_github(self) -> None:
"""Remove member from discord_to_github.json."""
with open(DATA_DIR / "discord_to_github.json") as f:
mappings = json.load(f)
if self.discord_id in mappings:
self.github_username = mappings[self.discord_id]
del mappings[self.discord_id]
with open(DATA_DIR / "discord_to_github.json", "w") as f:
json.dump(mappings, f, indent=2)
print(f"✅ Removed {self.github_username} from discord_to_github.json")
else:
print("⚠️ No GitHub username found in discord_to_github.json")
async def remove_from_github_teams(self) -> None:
"""Print instructions to remove member from GitHub organization teams."""
if not self.github_username:
print("⚠️ Skipping GitHub removal (no username)")
return
print(f"️ Please manually remove {self.github_username} from GitHub teams:")
print(f" - Team: {self.team_name}")
if self.role == "leader":
print(f" - Team: {self.team_name} Leaders")
async def remove_discord_roles(self) -> None:
"""Remove Discord roles from the member."""
with open(DATA_DIR / "team_message_ids.json") as f:
team_message_data = json.load(f)
if self.team_name not in team_message_data:
print(f"⚠️ Could not find role ID for team {self.team_name}")
return
self.team_role_id = team_message_data[self.team_name]["role_id"]
url = (
f"{DISCORD_API_BASE}/guilds/{DISCORD_GUILD_ID}"
f"/members/{self.discord_id}/roles/{self.team_role_id}"
)
async with self.session.delete(url, headers=self.headers) as response:
if response.status == 204:
print(f"✅ Removed {self.team_name} Discord role")
else:
print(f"❌ Failed to remove team role: {response.status}")
url = (
f"{DISCORD_API_BASE}/guilds/{DISCORD_GUILD_ID}"
f"/members/{self.discord_id}/roles/{COHORT_ROLE_ID}"
)
async with self.session.delete(url, headers=self.headers) as response:
if response.status == 204:
print("✅ Removed Spring 2026 Cohort Discord role")
else:
print(f"❌ Failed to remove cohort role: {response.status}")
async def send_team_message(self) -> None:
"""Send a message to the team channel announcing the removal."""
channel_id = TEXT_CHANNEL_IDS[self.team_name]
message = (
f"<@{self.discord_id}> has been removed from the cohort for inactivity."
)
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages"
data = {"content": message}
async with self.session.post(url, headers=self.headers, json=data) as response:
if response.status == 200:
print(f"✅ Sent removal message to {self.team_name} channel")
else:
print(f"❌ Failed to send message: {response.status}")
def generate_cohort_notes(self) -> str:
"""Generate text to add to COHORT_NOTES.md."""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
notes = f"\n## {today} - Member Removal\n\n"
notes += "| Discord ID | GitHub Username | Team | Role |\n"
notes += "|------------|-----------------|------|------|\n"
notes += (
f"| {self.discord_id} | {self.github_username or '-'} "
f"| {self.team_name} | {self.role.capitalize()} |\n"
)
return notes
async def main() -> None:
"""Remove a member from the cohort."""
if len(sys.argv) != 2:
print("Usage: python remove_member.py <discord_id>")
sys.exit(1)
discord_id = sys.argv[1]
async with MemberRemover(discord_id) as remover:
if not remover.find_member_info():
print(f"❌ Discord ID {discord_id} not found in any team")
sys.exit(1)
print("\n📋 Found member:")
print(f" Discord ID: {discord_id}")
print(f" Team: {remover.team_name}")
print(f" Role: {remover.role}")
print()
confirm = input("Proceed with removal? (yes/no): ")
if confirm.lower() != "yes":
print("❌ Removal cancelled")
sys.exit(0)
print("\n🚀 Starting removal process...\n")
remover.remove_from_team_assignments()
remover.remove_from_discord_to_github()
await remover.remove_from_github_teams()
await remover.remove_discord_roles()
await remover.send_team_message()
print("\n" + "=" * 80)
print("📝 Add this to COHORT_NOTES.md:")
print("=" * 80)
print(remover.generate_cohort_notes())
print("=" * 80)
print("\n✅ Member removal complete!")
if __name__ == "__main__":
asyncio.run(main())
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Remove resigned members from team_assignments.json.
Update RESIGNED_IDS with the Discord IDs of members who have resigned
before running this script.
"""
import json
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# Discord IDs of members who have resigned - update before running
RESIGNED_IDS: list[str] = []
def main() -> None:
"""Remove resigned members from team assignments."""
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
changes_made = []
for team in teams:
team_name = team["name"]
original_leaders = len(team["leaders"])
team["leaders"] = [lid for lid in team["leaders"] if lid not in RESIGNED_IDS]
if len(team["leaders"]) < original_leaders:
removed = original_leaders - len(team["leaders"])
changes_made.append(f"Removed {removed} leader(s) from {team_name}")
original_participants = len(team["participants"])
team["participants"] = [
pid for pid in team["participants"] if pid not in RESIGNED_IDS
]
if len(team["participants"]) < original_participants:
removed = original_participants - len(team["participants"])
changes_made.append(f"Removed {removed} participant(s) from {team_name}")
print(f"⚠️ {team_name}: Proficiency distribution may need updating")
with open(DATA_DIR / "team_assignments.json", "w") as f:
json.dump(teams, f, indent=2)
if changes_made:
print("✅ Successfully removed resigned members from team_assignments.json")
print("\nChanges made:")
for change in changes_made:
print(f" - {change}")
else:
print("No changes needed - resigned members were not found in team assignments")
print("\nUpdated team sizes:")
for team in teams:
total_members = len(team["leaders"]) + len(team["participants"])
print(
f" {team['name']}: {total_members} members "
f"({len(team['leaders'])} leaders, {len(team['participants'])} participants)" # noqa: E501
)
if __name__ == "__main__":
main()
-178
View File
@@ -1,178 +0,0 @@
"""Send formatted activity report tables to each team's Discord channel.
Parses catch_up_report.md and posts a monospace table of each member's Discord
and GitHub activity stats to their respective team channel.
Data files (place in data/):
- catch_up_report.md Activity report generated by catch_up_report.py
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
API_BASE = "https://discord.com/api/v10"
CHANNEL_IDS = {
"Crimson Dahlia": "1464316744909852682",
"Rose Camellia": "1464316751268286611",
"Amber Wisteria": "1464316761410113641",
"Ivory Orchid": "1464316770889240730",
"Teal Iris": "1464316776459407448",
"Peach Gardenia": "1464316785040953543",
"Violet Carnation": "1464316805261824032",
"Azure Lotus": "1464316814455472139",
"Coral Sunflower": "1464316819711066263",
"Indigo Tulip": "1464316826384072925",
"Scarlet Hydrangea": "1464316839306985506",
"Mint Narcissus": "1464316844251807952",
"Sage Marigold": "1464316850669093040",
}
REPORT_PATH = os.path.join(
os.path.dirname(__file__), "..", "..", "data", "catch_up_report.md"
)
FIELDS = [
("Discord Username", "Name", 18),
("Discord Messages", "Msgs", 5),
("PRs Opened", "PRs", 4),
("Issues Opened", "Issues", 6),
("Issue Comments", "Issue♟", 7),
("PR Comments", "PR♟", 5),
("PR Reviews", "Reviews", 7),
("Commits", "Commits", 7),
]
def parse_report(path: str) -> dict[str, list[dict]]:
"""Parse the markdown table from catch_up_report.md into team → rows."""
teams: dict[str, list[dict]] = {}
with open(path, encoding="utf-8") as f:
lines = f.readlines()
header_line = None
for i, line in enumerate(lines):
if line.startswith("| Discord ID |"):
header_line = i
break
if header_line is None:
raise ValueError("Could not find table header in report")
headers = [h.strip() for h in lines[header_line].strip().strip("|").split("|")]
for line in lines[header_line + 2 :]:
line = line.strip()
if not line.startswith("|"):
break
row_values = [v.strip() for v in line.strip().strip("|").split("|")]
row = dict(zip(headers, row_values))
team = row["Team"]
teams.setdefault(team, []).append(row)
return teams
def format_table(members: list[dict]) -> str:
"""Format a team's member list as a monospace table for Discord."""
members = sorted(members, key=lambda r: int(r["Discord Messages"]), reverse=True)
col_widths = [width for _, _, width in FIELDS]
col_headers = [header for _, header, _ in FIELDS]
name_col_index = 0
max_name = max(len(m["Discord Username"]) for m in members)
col_widths[name_col_index] = max(col_widths[name_col_index], max_name)
def pad(val: str, width: int, right_align: bool = False) -> str:
return val.rjust(width) if right_align else val.ljust(width)
header_row = " ".join(
pad(col_headers[i], col_widths[i], right_align=(i > 0))
for i in range(len(FIELDS))
)
separator = " ".join("-" * w for w in col_widths)
rows = []
for m in members:
source_keys = [key for key, _, _ in FIELDS]
values = [m[key] for key in source_keys]
row = " ".join(
pad(values[i], col_widths[i], right_align=(i > 0))
for i in range(len(FIELDS))
)
rows.append(row)
return "\n".join([header_row, separator] + rows)
async def send_message(
session: aiohttp.ClientSession, channel_id: str, content: str
) -> None:
"""Send a message to a Discord channel."""
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
url = f"{API_BASE}/channels/{channel_id}/messages"
while True:
async with session.post(
url, json={"content": content}, headers=headers
) as resp:
if resp.status == 429:
data = await resp.json()
retry_after = data.get("retry_after", 5)
print(f" Rate limited — sleeping {retry_after}s...")
await asyncio.sleep(retry_after)
continue
if resp.status not in (200, 201):
text = await resp.text()
print(f" ERROR {resp.status}: {text}")
return
async def main() -> None:
"""Send activity tables to all team channels."""
teams = parse_report(REPORT_PATH)
team_names = list(CHANNEL_IDS.keys())
print(f"Sending activity tables to {len(team_names)} channels...\n")
async with aiohttp.ClientSession() as session:
for i, team_name in enumerate(team_names, 1):
channel_id = CHANNEL_IDS[team_name]
members = teams.get(team_name, [])
if not members:
print(f" [{i}/{len(team_names)}] {team_name} — no data, skipping")
continue
table = format_table(members)
message = (
f"**{team_name} — Activity Report (Feb 1523)**\n```\n{table}\n```"
)
if len(message) > 2000:
print(
f" [{i}/{len(team_names)}] {team_name} — WARNING: "
f"message is {len(message)} chars (over 2000!)"
)
print(
f" [{i}/{len(team_names)}] Sending to {team_name}... ",
end="",
flush=True,
)
await send_message(session, channel_id, message)
print("sent!")
await asyncio.sleep(1)
print("\nAll done! 🌸")
if __name__ == "__main__":
asyncio.run(main())
-97
View File
@@ -1,97 +0,0 @@
"""Send biweekly check-in messages to all team Discord channels.
Posts a check-in prompt to each team channel and automatically creates a thread
for responses. Members who do not respond by the deadline may be removed for
inactivity.
Data files (place in data/):
- team_message_ids.json Channel IDs per team (generated by send_team_messages.py)
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import asyncio
import json
import os
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_API = "https://discord.com/api/v10"
GUILD_ID = "692816967895220344"
CHECK_IN_MESSAGE = """@everyone it is time for Naomi to do a check in. I need **each and every one of you** to respond to this message **in the thread** by Monday, or you **will be removed** for inactivity.
1. What have you achieved over the last two weeks?
2. What is your focus for the next two weeks?
3. How much time can you **reliably** commit to your team over the next two weeks?
4. Are you facing any **blockers** or **challenges** that are preventing you from contributing?
5. Do you need **help** from your team leaders or from Naomi to get unstuck?
"""
async def send_checkin_to_teams() -> None:
"""Send check-in message to all team channels (except Jade Jasmine)."""
with open(DATA_DIR / "team_message_ids.json") as f:
team_data = json.load(f)
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession() as session:
for team_name, data in team_data.items():
if team_name == "jade-jasmine":
print(f"⏭️ Skipping {team_name} (dissolved)")
continue
channel_id = data["channel_id"]
print(f"📤 Sending check-in to {team_name} (channel {channel_id})...")
async with session.post(
f"{DISCORD_API}/channels/{channel_id}/messages",
headers=headers,
json={"content": CHECK_IN_MESSAGE},
) as resp:
if resp.status == 200:
message_data = await resp.json()
message_id = message_data["id"]
print(f" ✅ Message sent! Message ID: {message_id}")
thread_name = (
f"Check-in Responses - {team_name.replace('-', ' ').title()}"
)
async with session.post(
f"{DISCORD_API}/channels/{channel_id}/messages/{message_id}/threads",
headers=headers,
json={
"name": thread_name,
"auto_archive_duration": 10080,
},
) as thread_resp:
if thread_resp.status == 201:
thread_data = await thread_resp.json()
thread_id = thread_data["id"]
print(f" 🧵 Thread created! Thread ID: {thread_id}")
else:
error_text = await thread_resp.text()
print(
f" ❌ Failed to create thread: "
f"{thread_resp.status} - {error_text}"
)
else:
error_text = await resp.text()
print(f" ❌ Failed to send message: {resp.status} - {error_text}")
await asyncio.sleep(1)
print("\n✅ Check-in messages sent to all teams!")
if __name__ == "__main__":
asyncio.run(send_checkin_to_teams())
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""Send a capacity check-in message to each team channel.
Posts a message asking whether the team feels able to complete their project
given their current member count, and invites them to request support if needed.
Data files (place in data/):
- team_message_ids.json Channel and role IDs per team (from send_team_messages.py)
- team_assignments.json Team rosters used to report current member counts
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import asyncio
import json
import os
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
NAOMI_ID = "465650873650118659"
def build_checkin_message(team_name: str, team: dict, role_id: str) -> str:
"""Build the team check-in message."""
total_members = len(team["leaders"]) + len(team["participants"])
num_leaders = len(team["leaders"])
num_participants = len(team["participants"])
leader_text = "leader" if num_leaders == 1 else "leaders"
participant_text = "participant" if num_participants == 1 else "participants"
return f"""## Team Check-In
Your team currently has **{total_members} members** ({num_leaders} {leader_text} + {num_participants} {participant_text}).
Given the recent changes to team size, we want to make sure you feel confident moving forward with your project. Please discuss as a team and let us know:
**Do you feel you can still complete your project with your current team size?**
If you have concerns about capacity, need additional support, or would like to discuss options (such as combining with another team or adjusting project scope), please ping <@{NAOMI_ID}> and we'll work together to find a solution.
Your success is the priority here - we want to make sure every team has what they need to build something amazing! 💜
-# <@&{role_id}>"""
async def send_message(
session: aiohttp.ClientSession, channel_id: str, content: str
) -> dict | None:
"""Send a message to a Discord channel."""
url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
payload = {"content": content}
async with session.post(url, headers=headers, json=payload) as resp:
if resp.status in [200, 201]:
return await resp.json()
error_text = await resp.text()
print(
f"❌ Failed to send message to channel {channel_id}: {resp.status} - {error_text}" # noqa: E501
)
return None
async def main() -> None:
"""Send check-in messages to all teams."""
with open(DATA_DIR / "team_message_ids.json") as f:
team_data = json.load(f)
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
async with aiohttp.ClientSession() as session:
print("📢 Sending team check-in messages...\n")
for team in teams:
team_name = team["name"]
channel_id = team_data[team_name]["channel_id"]
role_id = team_data[team_name]["role_id"]
print(f"Processing {team_name}...")
checkin_msg = build_checkin_message(team_name, team, role_id)
result = await send_message(session, channel_id, checkin_msg)
if result:
total = len(team["leaders"]) + len(team["participants"])
print(f" ✅ Sent check-in ({total} members)")
await asyncio.sleep(2)
print("\n✨ Done sending all team check-in messages!")
if __name__ == "__main__":
asyncio.run(main())
+3 -22
View File
@@ -1,29 +1,10 @@
"""Send initial welcome and roster messages to all team Discord channels.
Creates a pinned roster message in each team channel and stores the resulting
message ID, channel ID, and role ID in team_message_ids.json for use by
other scripts (send_checkin.py, update_roster_messages.py, etc.).
Data files (place in data/):
- team_assignments.json Team rosters with leaders and participants per team
- applicants_to_evaluate.json Applicant data including Discord channel/role IDs
Outputs (written to data/):
- team_message_ids.json Channel ID, message ID, and role ID per team
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import json
import os
import time
from pathlib import Path
import requests
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# Amari's bot token
TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
@@ -91,12 +72,12 @@ TEAMS = {
}
# Load team assignments and convert to dict by team name
with open(DATA_DIR / "team_assignments.json") 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(DATA_DIR / "applicants_to_evaluate.json") 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}
-73
View File
@@ -1,73 +0,0 @@
#!/bin/bash
# Update GitHub teams for Jade Jasmine dissolution
set -e # Exit on error
ORG="nhcarrigan-spring-2026-cohort"
echo "=== Phase 2: GitHub Team Changes ==="
echo ""
# Step 1: Remove all members from jade-jasmine team
echo "Step 1: Removing members from jade-jasmine team..."
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/Nikil-D-Gr8" || echo " - Nikil-D-Gr8 already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/amandaw800" || echo " - amandaw800 already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/hayden-html" || echo " - hayden-html already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/hbar1st" || echo " - hbar1st already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/rosacabrerac" || echo " - rosacabrerac already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/ArbitraryPie" || echo " - ArbitraryPie already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/builtbykabir" || echo " - builtbykabir already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine/memberships/Mista-Log" || echo " - Mista-Log already removed or not found"
echo "✅ jade-jasmine team cleared"
echo ""
# Step 2: Remove leaders from jade-jasmine-leaders team
echo "Step 2: Removing leaders from jade-jasmine-leaders team..."
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine-leaders/memberships/hayden-html" || echo " - hayden-html already removed or not found"
gh api --method DELETE "/orgs/$ORG/teams/jade-jasmine-leaders/memberships/Mista-Log" || echo " - Mista-Log already removed or not found"
echo "✅ jade-jasmine-leaders team cleared"
echo ""
# Step 3: Add members to new teams
echo "Step 3: Adding members to new teams..."
echo " - Adding Nikil-D-Gr8 to crimson-dahlia..."
gh api --method PUT "/orgs/$ORG/teams/crimson-dahlia/memberships/Nikil-D-Gr8" -f role=member
echo " - Adding amandaw800 to violet-carnation..."
gh api --method PUT "/orgs/$ORG/teams/violet-carnation/memberships/amandaw800" -f role=member
echo " - Adding hayden-html to teal-iris..."
gh api --method PUT "/orgs/$ORG/teams/teal-iris/memberships/hayden-html" -f role=member
echo " - Adding hbar1st to indigo-tulip..."
gh api --method PUT "/orgs/$ORG/teams/indigo-tulip/memberships/hbar1st" -f role=member
echo " - Adding rosacabrerac to scarlet-hydrangea..."
gh api --method PUT "/orgs/$ORG/teams/scarlet-hydrangea/memberships/rosacabrerac" -f role=member
echo " - Adding ArbitraryPie to peach-gardenia..."
gh api --method PUT "/orgs/$ORG/teams/peach-gardenia/memberships/ArbitraryPie" -f role=member
echo " - Adding builtbykabir to azure-lotus..."
gh api --method PUT "/orgs/$ORG/teams/azure-lotus/memberships/builtbykabir" -f role=member
echo " - Adding Mista-Log to ivory-orchid..."
gh api --method PUT "/orgs/$ORG/teams/ivory-orchid/memberships/Mista-Log" -f role=member
echo "✅ All members added to new teams"
echo ""
# Step 4: Add Mista-Log to ivory-orchid-leaders
echo "Step 4: Adding Mista-Log to ivory-orchid-leaders..."
gh api --method PUT "/orgs/$ORG/teams/ivory-orchid-leaders/memberships/Mista-Log" -f role=member
echo "✅ Mista-Log promoted to leader in Ivory Orchid"
echo ""
echo "=== Phase 2 Complete! ==="
echo ""
echo "Summary:"
echo "- ✅ jade-jasmine team cleared (8 members removed)"
echo "- ✅ jade-jasmine-leaders team cleared (2 leaders removed)"
echo "- ✅ 8 members added to their new teams"
echo "- ✅ Mista-Log promoted to leader in ivory-orchid"
-103
View File
@@ -1,103 +0,0 @@
"""Update team roster messages in Discord from team_assignments.json."""
import asyncio
import json
import os
from pathlib import Path
import aiohttp
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
API_BASE = "https://discord.com/api/v10"
async def edit_message(
session: aiohttp.ClientSession,
channel_id: str,
message_id: str,
content: str,
) -> bool:
"""Edit a Discord message."""
url = f"{API_BASE}/channels/{channel_id}/messages/{message_id}"
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
payload = {"content": content}
async with session.patch(url, headers=headers, json=payload) as resp:
if resp.status == 200:
return True
text = await resp.text()
print(f" ❌ Failed to edit message: {resp.status} - {text}")
return False
def generate_roster(team: dict, discord_to_github: dict) -> str:
"""Generate roster message for a team."""
team_name = team["name"]
leader_lines = [
f"- <@{discord_id}> ({discord_to_github.get(discord_id, 'Unknown')})"
for discord_id in team["leaders"]
]
participant_lines = [
f"- <@{discord_id}> ({discord_to_github.get(discord_id, 'Unknown')})"
for discord_id in team["participants"]
]
leaders_text = "\n".join(leader_lines) if leader_lines else "None"
participants_text = "\n".join(participant_lines) if participant_lines else "None"
return f"""# Team {team_name}
**Leaders:**
{leaders_text}
**Participants:**
{participants_text}"""
async def main() -> None:
"""Update roster messages for all teams."""
with open(DATA_DIR / "team_message_ids.json") as f:
team_data = json.load(f)
with open(DATA_DIR / "team_assignments.json") as f:
teams = json.load(f)
with open(DATA_DIR / "discord_to_github.json") as f:
discord_to_github = json.load(f)
async with aiohttp.ClientSession() as session:
for team in teams:
team_name = team["name"]
print(f"Updating roster for {team_name}...")
if team_name not in team_data:
print(" ⚠️ Team not found in team_message_ids.json")
continue
channel_id = team_data[team_name]["channel_id"]
message_id = team_data[team_name]["message_id"]
roster_content = generate_roster(team, discord_to_github)
success = await edit_message(
session, channel_id, message_id, roster_content
)
if success:
print(f" ✅ Updated (Message ID: {message_id})")
await asyncio.sleep(0.5)
print("\n✅ All roster messages updated!")
if __name__ == "__main__":
asyncio.run(main())
+2 -20
View File
@@ -1,26 +1,8 @@
"""Verify Discord user IDs from a markdown table of applicant data.
Reads a markdown table containing Discord IDs and checks each one against the
Discord API to confirm the user exists. Handles rate limits automatically.
Data files (place in data/):
- table.md Markdown table of applicants including a Discord ID column
Outputs (written to data/):
- discord_verification.json Verification result (valid/invalid) per Discord ID
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent.parent / "data"
# Configuration
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
@@ -28,7 +10,7 @@ GUILD_ID = "692816967895220344"
BASE_URL = "https://discord.com/api/v10"
# Read Discord IDs from table.md
with open(DATA_DIR / "table.md") as f:
with open("table.md") as f:
content = f.read()
lines = content.strip().split("\n")
@@ -122,7 +104,7 @@ print(f"Missing: {len(missing)}")
print(f"Errors: {len(errors)}")
# Save results
with open(DATA_DIR / "discord_verification.json", "w") as f:
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")
+5 -2
View File
@@ -79,8 +79,6 @@ ignore = [
"DTZ001",
# Ambiguous variable names - context makes it clear
"E741",
# Long lines in string literals (Discord messages, URLs)
"E501",
]
[tool.ruff.lint.pydocstyle]
@@ -94,3 +92,8 @@ quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
+4 -1
View File
@@ -1,7 +1,10 @@
# Development dependencies
ruff==0.14.14
pytest==8.3.5
pytest-mock==3.14.0
responses==0.25.3
pytest-asyncio==0.24.0
# Runtime dependencies
requests==2.32.3
aiohttp==3.11.12
pandas==3.0.1
+1
View File
@@ -0,0 +1 @@
# Test package for ephemere Python scripts
+207
View File
@@ -0,0 +1,207 @@
"""Tests for analyse_availability functions.
@copyright NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
"""
import sys
from pathlib import Path
import pytest
# Add the cohort directory to the path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "cohort"))
from analyse_availability import (
analyze_applicant_availability,
get_utc_blocks_for_hour,
local_hour_to_utc,
parse_time_slots,
parse_utc_offset,
)
class TestParseUtcOffset:
"""Tests for parse_utc_offset function."""
def test_positive_offset(self):
"""Should parse positive UTC offset."""
assert parse_utc_offset("Europe/London (UTC+0)") == 0
assert parse_utc_offset("Europe/Paris (UTC+1)") == 1
assert parse_utc_offset("Asia/Tokyo (UTC+9)") == 9
def test_negative_offset(self):
"""Should parse negative UTC offset."""
assert parse_utc_offset("America/New_York (UTC-5)") == -5
assert parse_utc_offset("America/Los_Angeles (UTC-8)") == -8
def test_offset_with_minutes(self):
"""Should parse offset with minutes component."""
assert parse_utc_offset("Asia/Kolkata (UTC+5:30)") == 5.5
assert parse_utc_offset("Asia/Kathmandu (UTC+5:45)") == 5.75
def test_negative_offset_with_minutes(self):
"""Should parse negative offset with minutes."""
assert parse_utc_offset("Canada/Newfoundland (UTC-3:30)") == -3.5
def test_no_match_returns_zero(self):
"""Should return 0 when no UTC offset found."""
assert parse_utc_offset("Unknown/Timezone") == 0
assert parse_utc_offset("") == 0
class TestParseTimeSlots:
"""Tests for parse_time_slots function."""
def test_single_slot(self):
"""Should parse a single time slot."""
result = parse_time_slots("17:00-18:00")
assert result == [(17, 18)]
def test_multiple_slots(self):
"""Should parse multiple time slots separated by semicolon."""
result = parse_time_slots("07:00-08:00; 19:00-20:00")
assert result == [(7, 8), (19, 20)]
def test_na_values(self):
"""Should return empty list for N/A values."""
assert parse_time_slots("N/A") == []
assert parse_time_slots("na") == []
assert parse_time_slots("") == []
def test_wider_time_range(self):
"""Should parse wider time ranges."""
result = parse_time_slots("09:00-17:00")
assert result == [(9, 17)]
def test_midnight_crossing_slots(self):
"""Should parse time slots that approach midnight."""
result = parse_time_slots("22:00-23:00")
assert result == [(22, 23)]
class TestLocalHourToUtc:
"""Tests for local_hour_to_utc function."""
def test_positive_offset(self):
"""Should convert local hour with positive UTC offset."""
assert local_hour_to_utc(12, 1) == 11 # Noon in UTC+1 -> 11:00 UTC
assert local_hour_to_utc(0, 9) == 15 # Midnight in UTC+9 -> 15:00 UTC
def test_negative_offset(self):
"""Should convert local hour with negative UTC offset."""
assert local_hour_to_utc(12, -5) == 17 # Noon in UTC-5 -> 17:00 UTC
assert local_hour_to_utc(20, -8) == 4 # 8pm in UTC-8 -> 4:00 UTC (next day)
def test_wrapping_around_midnight(self):
"""Should wrap around correctly at midnight."""
assert local_hour_to_utc(1, 5) == 20 # 1am in UTC+5 -> 20:00 UTC (prev day)
assert local_hour_to_utc(23, -3) == 2 # 11pm in UTC-3 -> 2:00 UTC (next day)
def test_zero_offset(self):
"""Should return same hour for zero offset."""
assert local_hour_to_utc(15, 0) == 15
class TestGetUtcBlocksForHour:
"""Tests for get_utc_blocks_for_hour function."""
def test_mornings_block(self):
"""Should return mornings for 6-11 UTC."""
assert "mornings" in get_utc_blocks_for_hour(6)
assert "mornings" in get_utc_blocks_for_hour(9)
assert "mornings" in get_utc_blocks_for_hour(11)
assert "mornings" not in get_utc_blocks_for_hour(12)
def test_afternoons_block(self):
"""Should return afternoons for 12-17 UTC."""
assert "afternoons" in get_utc_blocks_for_hour(12)
assert "afternoons" in get_utc_blocks_for_hour(15)
assert "afternoons" in get_utc_blocks_for_hour(17)
assert "afternoons" not in get_utc_blocks_for_hour(18)
def test_evenings_block(self):
"""Should return evenings for 18-23 UTC."""
assert "evenings" in get_utc_blocks_for_hour(18)
assert "evenings" in get_utc_blocks_for_hour(21)
assert "evenings" in get_utc_blocks_for_hour(23)
assert "evenings" not in get_utc_blocks_for_hour(0)
def test_nights_block(self):
"""Should return nights for 0-5 UTC."""
assert "nights" in get_utc_blocks_for_hour(0)
assert "nights" in get_utc_blocks_for_hour(3)
assert "nights" in get_utc_blocks_for_hour(5)
assert "nights" not in get_utc_blocks_for_hour(6)
class TestAnalyzeApplicantAvailability:
"""Tests for analyze_applicant_availability function."""
def test_basic_availability(self):
"""Should analyze basic availability correctly."""
day_slots = {
"Monday": [(9, 12)],
"Tuesday": [(9, 12)],
"Wednesday": [(9, 12)],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
assert result["utc_offset"] == 0
assert "mornings" in result["available_blocks"]
def test_no_availability(self):
"""Should return empty blocks when no availability."""
day_slots = {
"Monday": [],
"Tuesday": [],
"Wednesday": [],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
assert result["available_blocks"] == []
assert result["total_unique_utc_hours"] == 0
def test_timezone_conversion(self):
"""Should correctly convert timezones."""
day_slots = {
"Monday": [(17, 20)], # 5pm-8pm local
"Tuesday": [(17, 20)],
"Wednesday": [(17, 20)],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability(
"America/New_York (UTC-5)", day_slots
)
assert result["utc_offset"] == -5
# 5pm-8pm in UTC-5 = 22:00-01:00 UTC -> evenings/nights
assert "evenings" in result["available_blocks"]
def test_block_count_threshold(self):
"""Should only include blocks with 3+ occurrences."""
day_slots = {
"Monday": [(9, 10)], # Only 1 hour
"Tuesday": [(9, 10)], # Only 1 hour
"Wednesday": [],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
# Only 2 hours, threshold is 3
assert "mornings" not in result["available_blocks"]
+23 -5
View File
@@ -96,7 +96,7 @@ select_option() {
# Step 1: Select Language
echo ""
languages=("TypeScript" "Python")
languages=("TypeScript" "Python" "Bash")
select_option "Select a language:" "${languages[@]}"
lang_index=$?
language="${languages[$lang_index]}"
@@ -109,7 +109,7 @@ if [ "$language" == "TypeScript" ]; then
runner="pnpm tsx"
# Get subdirectories as categories (excluding utils and interfaces)
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'utils' ! -name 'interfaces' -exec basename {} \; | sort)
else
elif [ "$language" == "Python" ]; then
script_dir="python"
runner="uv run python"
# Get subdirectories as categories (excluding __pycache__ and .venv)
@@ -118,6 +118,12 @@ else
if ls "$script_dir"/*.py &>/dev/null 2>&1; then
categories=("Root Scripts" "${categories[@]}")
fi
else # Bash
script_dir="bash"
runner="bash"
# Get subdirectories as categories
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort)
# Add "Root Scripts" option for bash files in root
fi
if [ ${#categories[@]} -eq 0 ]; then
@@ -134,13 +140,20 @@ 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"
if [ "$language" == "Python" ]; then
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort)
elif [ "$language" == "Bash" ]; then
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.sh" -exec basename {} \; | sort)
fi
elif [ "$language" == "TypeScript" ]; then
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.ts" -exec basename {} \; | sort)
else
elif [ "$language" == "Python" ]; then
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.py" ! -name "__init__.py" -exec basename {} \; | sort)
else # Bash
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.sh" -exec basename {} \; | sort)
fi
if [ ${#scripts[@]} -eq 0 ]; then
@@ -159,8 +172,10 @@ if [ "$category" == "Root Scripts" ]; then
script_path="$script"
elif [ "$language" == "TypeScript" ]; then
script_path="src/$category/$script"
else
elif [ "$language" == "Python" ]; then
script_path="$category/$script"
else # Bash
script_path="bash/$category/$script"
fi
# Show what we're about to run
@@ -178,10 +193,13 @@ 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
elif [ "$language" == "Python" ]; then
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"
else # Bash
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=$?
+8 -1
View File
@@ -5,7 +5,10 @@
"main": "index.js",
"type": "module",
"scripts": {
"start": "op run --env-file=prod.env --no-masking -- tsx"
"start": "op run --env-file=prod.env --no-masking -- tsx",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"keywords": [],
"author": "",
@@ -24,5 +27,9 @@
"open": "11.0.0",
"tsx": "4.20.5",
"typescript": "5.9.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.4",
"vitest": "3.2.4"
}
}
+332
View File
@@ -44,9 +44,20 @@ importers:
typescript:
specifier: 5.9.2
version: 5.9.2
devDependencies:
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))
vitest:
specifier: 3.2.4
version: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -210,10 +221,31 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@es-joy/jsdoccomment@0.49.0':
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
engines: {node: '>=16'}
@@ -589,9 +621,27 @@ packages:
'@types/node':
optional: true
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@nhcarrigan/eslint-config@5.2.0':
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
engines: {node: '>=22', pnpm: '>=9'}
@@ -672,6 +722,10 @@ packages:
'@octokit/types@14.1.0':
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@pkgr/core@0.1.2':
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -1136,6 +1190,15 @@ packages:
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
peerDependencies:
'@vitest/browser': 3.2.4
vitest: 3.2.4
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/eslint-plugin@1.1.24':
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
peerDependencies:
@@ -1200,10 +1263,18 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'}
@@ -1251,6 +1322,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.11:
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1434,12 +1508,18 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.210:
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@@ -1679,6 +1759,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1722,6 +1806,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
@@ -1779,6 +1867,9 @@ packages:
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
iconv-lite@0.7.0:
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
engines: {node: '>=0.10.0'}
@@ -1938,10 +2029,32 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2013,9 +2126,19 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2042,6 +2165,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2127,6 +2254,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2150,6 +2280,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -2379,6 +2513,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@3.9.0:
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
@@ -2390,6 +2527,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -2413,6 +2554,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2443,6 +2588,10 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2658,6 +2807,14 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wsl-utils@0.3.0:
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
engines: {node: '>=20'}
@@ -2672,6 +2829,11 @@ packages:
snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -3157,8 +3319,23 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@es-joy/jsdoccomment@0.49.0':
dependencies:
comment-parser: 1.4.1
@@ -3451,8 +3628,31 @@ snapshots:
optionalDependencies:
'@types/node': 24.3.0
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(playwright@1.55.0)(react@19.1.1)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
@@ -3560,6 +3760,9 @@ snapshots:
dependencies:
'@octokit/openapi-types': 25.1.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.1.2': {}
'@rollup/rollup-android-arm-eabi@4.49.0':
@@ -4163,6 +4366,25 @@ snapshots:
'@typescript-eslint/types': 8.41.0
eslint-visitor-keys: 4.2.1
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.11
debug: 4.4.1
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.18
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
transitivePeerDependencies:
- supports-color
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2)
@@ -4234,10 +4456,14 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
are-docs-informative@0.0.2: {}
argparse@2.0.1: {}
@@ -4313,6 +4539,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.11:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-function@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -4488,10 +4720,14 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.210: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
error-ex@1.3.2:
dependencies:
is-arrayish: 0.2.1
@@ -4900,6 +5136,11 @@ snapshots:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fsevents@2.3.2:
optional: true
@@ -4955,6 +5196,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
globals@13.24.0:
dependencies:
type-fest: 0.20.2
@@ -5005,6 +5255,8 @@ snapshots:
hosted-git-info@2.8.9: {}
html-escaper@2.0.2: {}
iconv-lite@0.7.0:
dependencies:
safer-buffer: 2.1.2
@@ -5157,6 +5409,27 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
debug: 4.4.1
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -5166,6 +5439,14 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -5226,10 +5507,22 @@ snapshots:
loupe@3.2.1: {}
lru-cache@10.4.3: {}
magic-string@0.30.18:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.2
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -5251,6 +5544,8 @@ snapshots:
minimist@1.2.8: {}
minipass@7.1.2: {}
ms@2.1.3: {}
mute-stream@2.0.0: {}
@@ -5352,6 +5647,8 @@ snapshots:
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5374,6 +5671,11 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-type@4.0.0: {}
pathe@2.0.3: {}
@@ -5627,6 +5929,8 @@ snapshots:
stackback@0.0.2: {}
std-env@3.10.0: {}
std-env@3.9.0: {}
stop-iteration-iterator@1.1.0:
@@ -5640,6 +5944,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8
@@ -5688,6 +5998,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-indent@3.0.0:
@@ -5713,6 +6027,12 @@ snapshots:
'@pkgr/core': 0.1.2
tslib: 2.8.1
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.5.0
minimatch: 9.0.5
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -5960,6 +6280,18 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
wsl-utils@0.3.0:
dependencies:
is-wsl: 3.1.0
@@ -1,15 +1,4 @@
/**
* @file Delete hidden/outdated translations from a Crowdin project.
* Reads a list of string IDs from data/crowdin-strings.json, checks which
* have already been processed via the log file, and deletes translations
* for unprocessed strings across all active languages in parallel.
* Data files (place in data/):
* - crowdin-strings.json String IDs to clear (from crowdin/writeData.ts)
* - crowdin-strings-hidden.txt Log of already-processed string IDs
* Env vars:
* CROWDIN_PROJECT_ID - Crowdin project numeric ID
* CROWDIN_API_URL - Base URL of the Crowdin API
* CROWDIN_TOKEN - Crowdin personal access token.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,12 +1,4 @@
/**
* @file Trigger a pre-translation run on a Crowdin project and wait for completion.
* Fetches all active languages and files, submits a pre-translation request,
* and polls for progress every 5 seconds until it reaches 100%%.
* Data files: None
* Env vars:
* CROWDIN_PROJECT_ID - Crowdin project numeric ID
* CROWDIN_API_URL - Base URL of the Crowdin API
* CROWDIN_TOKEN - Crowdin personal access token.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -0,0 +1,107 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getFiles } from "../getFiles.js";
describe("getFiles", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch files from a project", async() => {
const mockFiles = [
{ data: { id: 1, name: "file1.json", path: "/file1.json" } },
{ data: { id: 2, name: "file2.json", path: "/file2.json" } },
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockFiles });
},
} as Response);
const result = await getFiles(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual(mockFiles);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should handle pagination when there are 500+ files", async() => {
const files500 = Array.from({ length: 500 }, (_, index) => {
return { data: { id: index, name: `file${String(index)}.json` } };
});
const filesRemaining = [
{ data: { id: 500, name: "file500.json" } },
{ data: { id: 501, name: "file501.json" } },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: files500 });
},
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: filesRemaining });
},
} as Response);
const result = await getFiles(
"123",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toHaveLength(502);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
expect.any(Object),
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://api.crowdin.com/api/v2/projects/123/files?limit=500&offset=500",
expect.any(Object),
);
});
it("should return empty array when no files exist", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: [] });
},
} as Response);
const result = await getFiles(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,98 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getLanguages } from "../getLanguages.js";
describe("getLanguages", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch project and return target language IDs", async() => {
const mockResponse = {
data: {
id: 1,
name: "Test Project",
targetLanguageIds: [ "de", "fr", "es", "ja" ],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
const result = await getLanguages(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual([ "de", "fr", "es", "ja" ]);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should return empty array when no target languages", async() => {
const mockResponse = {
data: {
id: 2,
name: "Empty Project",
targetLanguageIds: [],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
const result = await getLanguages(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
});
it("should use the correct API URL", async() => {
const mockResponse = {
data: {
targetLanguageIds: [ "en" ],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
await getLanguages(
"789",
"https://custom.crowdin.com/v2",
"my-token",
);
expect(global.fetch).toHaveBeenCalledWith(
"https://custom.crowdin.com/v2/projects/789",
expect.any(Object),
);
});
});
@@ -0,0 +1,139 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getStrings } from "../getStrings.js";
describe("getStrings", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch strings from a project", async() => {
const mockStrings = [
{ data: { id: 1, text: "Hello", identifier: "greeting" } },
{ data: { id: 2, text: "Goodbye", identifier: "farewell" } },
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockStrings });
},
} as Response);
const result = await getStrings(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual([
{ id: 1, text: "Hello", identifier: "greeting" },
{ id: 2, text: "Goodbye", identifier: "farewell" },
]);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should handle pagination when there are 500+ strings", async() => {
const strings500 = Array.from({ length: 500 }, (_, index) => {
return { data: { id: index, text: `String ${String(index)}` } };
});
const stringsRemaining = [
{ data: { id: 500, text: "String 500" } },
{ data: { id: 501, text: "String 501" } },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: strings500 });
},
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: stringsRemaining });
},
} as Response);
const result = await getStrings(
"123",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toHaveLength(502);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500&offset=500",
expect.any(Object),
);
});
it("should return empty array when no strings exist", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: [] });
},
} as Response);
const result = await getStrings(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("should unwrap the data from each string object", async() => {
const mockStrings = [
{
data: {
context: "greeting context",
id: 1,
identifier: "hello",
isHidden: false,
text: "Hello World",
},
},
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockStrings });
},
} as Response);
const result = await getStrings(
"789",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result[0]).toEqual({
context: "greeting context",
id: 1,
identifier: "hello",
isHidden: false,
text: "Hello World",
});
});
});
-6
View File
@@ -1,10 +1,4 @@
/**
* @file Create discussion threads for CYC talk submissions in a Discord channel.
* Iterates over a hardcoded list of talk titles and speakers and creates a
* Discord forum thread for each one. Update the data array and CHANNEL_ID before running.
* Data files: None (talk data is hardcoded in the script)
* Env vars:
* DISCORD_TOKEN - Bot token for the Discord API.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-10
View File
@@ -1,14 +1,4 @@
/**
* @file Count and categorise all Discord servers a user belongs to.
* Uses the Discord OAuth2 PKCE flow with a local callback server to authenticate,
* then fetches and categorises all guilds (owned, admin, moderated, partnered,
* verified, community, discoverable) and serves an HTML dashboard on localhost.
* Data files: None
* Env vars:
* DISCORD_CLIENT_ID - OAuth2 application client ID
* DISCORD_CLIENT_SECRET - OAuth2 application client secret (optional)
* DISCORD_REDIRECT_URI - OAuth2 redirect URI (default: http://127.0.0.1:8721/callback)
* DISCORD_SCOPES - OAuth2 scopes (default: identify guilds).
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,12 +1,4 @@
/**
* @file Set auto-close settings on all Discourse forum categories.
* Fetches all categories and subcategories and enables auto-close based on
* last post (672 hours / 28 days) for each one.
* Data files: None
* Env vars:
* DISCOURSE_URL - Base URL of the Discourse instance
* DISCOURSE_API_KEY - Discourse API key
* DISCOURSE_API_USERNAME - Discourse API username.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,11 +1,4 @@
/**
* @file Close inactive Discourse topics that have had no activity for 28+ days.
* Skips topics older than 6 months (already archived) and any that are already closed.
* Data files: None
* Env vars:
* DISCOURSE_URL - Base URL of the Discourse instance
* DISCOURSE_API_KEY - Discourse API key
* DISCOURSE_API_USERNAME - Discourse API username.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,10 +1,4 @@
/**
* @file Delete a file from every repository across all nhcarrigan Gitea orgs.
* Prompts for the file path to delete, then removes it from every repo
* across nhcarrigan, nhcarrigan-private, and nhcarrigan-games.
* Data files: None
* Env vars:
* GITEA_TOKEN - Gitea personal access token with repo write permissions.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-7
View File
@@ -1,11 +1,4 @@
/**
* @file Upload a file from data/ to every repository across all nhcarrigan Gitea orgs.
* Prompts for the local filename and destination path, then creates or updates
* the file in every repo across nhcarrigan, nhcarrigan-private, and nhcarrigan-games.
* Data files (place in data/):
* - Any file to upload (prompted interactively)
* Env vars:
* GITEA_TOKEN - Gitea personal access token with repo write permissions.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,11 +1,4 @@
/**
* @file Conditionally upload a file to Gitea repos based on whether another file exists.
* Prompts for the file to upload, a condition file path, and whether to upload
* when the condition file exists or does not exist.
* Data files (place in data/):
* - Any file to upload (prompted interactively)
* Env vars:
* GITEA_TOKEN - Gitea personal access token with repo write permissions.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,12 +1,4 @@
/**
* @file Audit npm packages across a GitHub organisation for known vulnerabilities.
* Lists all repositories in the target org, fetches each package.json, and checks
* for usage of packages in the hardcoded vulnerable package list.
* Update the vulnerable package list and orgName constant before running.
* Outputs (written to data/):
* - npm-vulnerabilities.txt Repos and packages with vulnerability findings
* Env vars:
* GITHUB_TOKEN - GitHub personal access token with org read permissions.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-217
View File
@@ -1,217 +0,0 @@
/**
* @file Onboard a new mentee to the nhcarrigan-mentorship GitHub organisation.
* Prompts for the mentee's Discord ID, full name, and GitHub username, creates
* a personal repository in the org, adds the mentee as a collaborator with
* maintain permissions, and posts a welcome message to the Discord channel.
* Data files: None
* Env vars:
* GITHUB_TOKEN - GitHub personal access token with org and repo permissions
* DISCORD_TOKEN - Bot token for the Discord API.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { input } from "@inquirer/prompts";
import { Octokit } from "@octokit/rest";
// Environment variable checks
const githubToken = process.env.GITHUB_TOKEN;
const discordToken = process.env.DISCORD_TOKEN;
if (githubToken === undefined || githubToken === "") {
throw new Error("GITHUB_TOKEN is not set");
}
if (discordToken === undefined || discordToken === "") {
throw new Error("DISCORD_TOKEN is not set");
}
const octokit = new Octokit({ auth: githubToken });
/**
* Converts a full name to kebab-case.
* @param fullName - The full name to convert.
* @returns The kebab-case version.
*/
function toKebabCase(fullName: string): string {
return fullName.trim().toLowerCase().
replaceAll(/[^\d\sa-z-]/g, "").
replaceAll(/\s+/g, "-");
}
/**
* Prompts for mentee information.
* @returns The mentee information.
*/
async function getMenteeInfo(): Promise<{
discordId: string;
fullName: string;
githubUsername: string;
}> {
const discordId = await input({
message: "Enter the mentee's Discord ID:",
validate: (value) => {
const trimmed = value.trim();
if (trimmed === "") {
return "Discord ID cannot be empty";
}
if (!/^\d+$/.test(trimmed)) {
return "Discord ID must be numeric";
}
return true;
},
});
const fullName = await input({
message: "Enter the mentee's full name:",
validate: (value) => {
if (value.trim() === "") {
return "Full name cannot be empty";
}
return true;
},
});
const githubUsername = await input({
message: "Enter the mentee's GitHub username:",
validate: (value) => {
if (value.trim() === "") {
return "GitHub username cannot be empty";
}
return true;
},
});
return { discordId, fullName, githubUsername };
}
interface RepoData {
/**
* Using camelCase interface for internal consistency.
*/
htmlUrl: string;
}
/**
* Creates a public GitHub repository in the nhcarrigan-mentorship organization with auto-init enabled.
* @param repoName - The kebab-case repository name derived from mentee's name.
* @param fullName - The mentee's full name used in repository description.
* @returns The created repository data with HTML URL.
*/
async function createRepository(
repoName: string,
fullName: string,
): Promise<RepoData> {
console.log("\n1️⃣ Creating repository...");
const { data: repo } = await octokit.rest.repos.createInOrg({
// eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub API
auto_init: true,
description: `Mentorship repository for ${fullName}`,
name: repoName,
org: "nhcarrigan-mentorship",
private: false,
});
console.log("✅ Repository created successfully!");
return { htmlUrl: repo.html_url };
}
/**
* Adds the mentee as a collaborator.
* @param repoName - The repository name.
* @param githubUsername - The mentee's GitHub username.
*/
async function addCollaborator(
repoName: string,
githubUsername: string,
): Promise<void> {
console.log("\n2️⃣ Adding collaborator...");
await octokit.rest.repos.addCollaborator({
owner: "nhcarrigan-mentorship",
permission: "maintain",
repo: repoName,
username: githubUsername,
});
console.log("✅ Collaborator added with maintain permissions!");
}
/**
* Sends a welcome message to Discord.
* @param discordId - The mentee's Discord ID.
* @param repoUrl - The repository URL.
*/
async function sendDiscordMessage(
discordId: string,
repoUrl: string,
): Promise<void> {
console.log("\n3️⃣ Sending Discord welcome message...");
const channelId = "1400589073613062204";
const welcomeMessage = {
content: `<@${discordId}> Welcome to the mentorship programme! 🎉\n\nYour personal repository has been created: ${repoUrl}\n\nYou have been added as a collaborator with maintain permissions. Feel free to use this space to practice, experiment, and work on projects. I'm here to help guide you on your journey!\n\nLooking forward to working with you! 💖`,
};
const discordResponse = await fetch(
`https://discord.com/api/v10/channels/${channelId}/messages`,
{
body: JSON.stringify(welcomeMessage),
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API
"Authorization": `Bot ${String(discordToken)}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP headers
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!discordResponse.ok) {
const error = await discordResponse.text();
throw new Error(`Discord API error: ${error}`);
}
console.log("✅ Discord welcome message sent!");
}
/**
* Main function to onboard a new mentee.
*/
async function main(): Promise<void> {
console.log("🚀 Starting mentorship onboarding process...\n");
try {
const { discordId, fullName, githubUsername } = await getMenteeInfo();
const repoName = toKebabCase(fullName);
console.log(`\n📦 Repository will be created as: nhcarrigan-mentorship/${repoName}`);
const repo = await createRepository(repoName, fullName);
await addCollaborator(repoName, githubUsername);
await sendDiscordMessage(discordId, repo.htmlUrl);
// Success summary
console.log("\n🎊 Onboarding completed successfully!");
console.log(`\n📋 Summary:`);
console.log(` Mentee: ${fullName} (@${githubUsername})`);
console.log(` Discord ID: ${discordId}`);
console.log(` Repository: ${repo.htmlUrl}`);
console.log(` Permissions: Maintain`);
console.log(` Welcome message sent to Discord channel`);
} catch (error) {
console.error("\n❌ Error during onboarding:");
if (error instanceof Error) {
console.error(error.message);
} else {
console.error(error);
}
process.exit(1);
}
}
// Run the script
await main().catch((error: unknown) => {
console.error("❌ Unexpected error:", error);
process.exit(1);
});
-7
View File
@@ -1,11 +1,4 @@
/**
* @file Post user story markdown files as GitHub issue descriptions.
* Reads all markdown files from data/stories/ and updates the corresponding
* GitHub issue with that content. Filename format: {repo-name}-{issue-number}.md.
* Data files (place in data/stories/):
* - {repo-name}-{issue-number}.md User story content for each issue
* Env vars:
* GITHUB_TOKEN - GitHub personal access token with repo write permissions.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-6
View File
@@ -1,10 +1,4 @@
/**
* @file Tag MP3 files with ID3v2 metadata and album art for Neuro-sama tracks.
* Reads all MP3 files from the download directory, extracts the title from
* the filename, sets the artist to "Neuro-sama", and applies the cover image.
* Update the directory and cover constants before running.
* Data files: None (reads from a local directory path defined in the script)
* Env vars: None (uses eyeD3 and id3v2 CLI tools).
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-9
View File
@@ -1,13 +1,4 @@
/**
* @file Upload all files in the data/ directory to an S3-compatible bucket.
* Displays a tree of files to upload, prompts for confirmation, then uploads
* with a progress bar showing per-file status.
* Data files (place in data/):
* - All files to upload are read from the data/ directory recursively
* Env vars:
* AWS_ACCESS_KEY_ID - S3 access key
* AWS_SECRET_ACCESS_KEY - S3 secret key
* S3_ENDPOINT - S3-compatible endpoint URL.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-8
View File
@@ -1,12 +1,4 @@
/**
* @file Fix incorrect Content-Type metadata on objects in an S3-compatible bucket.
* Lists all objects, detects MIME type from extension, and re-uploads metadata
* for any file whose stored Content-Type does not match the expected type.
* Data files: None
* Env vars:
* AWS_ACCESS_KEY_ID - S3 access key
* AWS_SECRET_ACCESS_KEY - S3 secret key
* S3_ENDPOINT - S3-compatible endpoint URL.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-8
View File
@@ -1,12 +1,4 @@
/**
* @file Delete all objects from an S3-compatible bucket.
* Prompts for the bucket name, requires double confirmation (type name + yes/no),
* then lists and deletes all objects in batches with a progress bar.
* Data files: None
* Env vars:
* AWS_ACCESS_KEY_ID - S3 access key
* AWS_SECRET_ACCESS_KEY - S3 secret key
* S3_ENDPOINT - S3-compatible endpoint URL.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
-8
View File
@@ -1,12 +1,4 @@
/**
* @file Upload a single file from the data/ directory to an S3-compatible bucket.
* Prompts for the local filename and the destination path in the bucket.
* Data files (place in data/):
* - Any file to upload (prompted interactively)
* Env vars:
* AWS_ACCESS_KEY_ID - S3 access key
* AWS_SECRET_ACCESS_KEY - S3 secret key
* S3_ENDPOINT - S3-compatible endpoint URL.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -1,12 +1,5 @@
/* eslint-disable max-lines -- Necessary for all of the HTML templating. */
/**
* @file Generate a public HTML security report from DefectDojo findings.
* Fetches all active, verified findings from DefectDojo and produces a styled
* HTML dashboard grouped by project with severity counts.
* Outputs (written to data/):
* - public_security_report.html HTML security report
* Env vars:
* DOJO_TOKEN - DefectDojo API token.
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
@@ -0,0 +1,158 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { backoffAndRetry } from "../backoffAndRetry.js";
describe("backoffAndRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(global, "fetch");
vi.spyOn(console, "error").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should return JSON data on successful response", async() => {
const mockData = { success: true, data: "test" };
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const result = await backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api",
{},
);
});
it("should pass options to fetch", async() => {
const mockData = { result: "success" };
const options: RequestInit = {
headers: { authorization: "Bearer token" },
method: "POST",
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
await backoffAndRetry("https://example.com/api", options);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api",
options,
);
});
it("should retry after 5 seconds on 429 response", async() => {
const mockData = { result: "success" };
vi.mocked(global.fetch).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const resultPromise = backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("should handle multiple 429 responses with backoff", async() => {
const mockData = { result: "finally" };
vi.mocked(global.fetch).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const resultPromise = backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it("should return null on non-429 error response", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
it("should return null on fetch error", async() => {
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"));
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
it("should return null on JSON parse error", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.reject(new Error("Invalid JSON"));
},
ok: true,
} as Response);
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
});
@@ -0,0 +1,242 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect } from "vitest";
import { getMimeType } from "../mimeType.js";
describe("getMimeType", () => {
describe("image types", () => {
it("should return image/png for .png files", () => {
expect(getMimeType("image.png")).toBe("image/png");
expect(getMimeType("/path/to/image.png")).toBe("image/png");
});
it("should return image/jpeg for .jpg and .jpeg files", () => {
expect(getMimeType("photo.jpg")).toBe("image/jpeg");
expect(getMimeType("photo.jpeg")).toBe("image/jpeg");
});
it("should return image/gif for .gif files", () => {
expect(getMimeType("animation.gif")).toBe("image/gif");
});
it("should return image/webp for .webp files", () => {
expect(getMimeType("image.webp")).toBe("image/webp");
});
it("should return image/svg+xml for .svg files", () => {
expect(getMimeType("icon.svg")).toBe("image/svg+xml");
});
it("should return image/bmp for .bmp files", () => {
expect(getMimeType("bitmap.bmp")).toBe("image/bmp");
});
it("should return image/tiff for .tif and .tiff files", () => {
expect(getMimeType("scan.tif")).toBe("image/tiff");
expect(getMimeType("scan.tiff")).toBe("image/tiff");
});
it("should return image/x-icon for .ico files", () => {
expect(getMimeType("favicon.ico")).toBe("image/x-icon");
});
});
describe("video types", () => {
it("should return video/mp4 for .mp4 files", () => {
expect(getMimeType("video.mp4")).toBe("video/mp4");
});
it("should return video/webm for .webm files", () => {
expect(getMimeType("video.webm")).toBe("video/webm");
});
it("should return video/x-msvideo for .avi files", () => {
expect(getMimeType("video.avi")).toBe("video/x-msvideo");
});
it("should return video/quicktime for .mov files", () => {
expect(getMimeType("video.mov")).toBe("video/quicktime");
});
it("should return video/x-matroska for .mkv files", () => {
expect(getMimeType("video.mkv")).toBe("video/x-matroska");
});
});
describe("audio types", () => {
it("should return audio/mpeg for .mp3 files", () => {
expect(getMimeType("song.mp3")).toBe("audio/mpeg");
});
it("should return audio/wav for .wav files", () => {
expect(getMimeType("sound.wav")).toBe("audio/wav");
});
it("should return audio/ogg for .ogg files", () => {
expect(getMimeType("audio.ogg")).toBe("audio/ogg");
});
it("should return audio/aac for .aac files", () => {
expect(getMimeType("audio.aac")).toBe("audio/aac");
});
it("should return audio/flac for .flac files", () => {
expect(getMimeType("music.flac")).toBe("audio/flac");
});
});
describe("document types", () => {
it("should return application/pdf for .pdf files", () => {
expect(getMimeType("document.pdf")).toBe("application/pdf");
});
it("should return application/json for .json files", () => {
expect(getMimeType("data.json")).toBe("application/json");
});
it("should return text/plain for .txt files", () => {
expect(getMimeType("readme.txt")).toBe("text/plain");
});
it("should return text/markdown for .md files", () => {
expect(getMimeType("README.md")).toBe("text/markdown");
});
it("should return text/html for .html and .htm files", () => {
expect(getMimeType("page.html")).toBe("text/html");
expect(getMimeType("page.htm")).toBe("text/html");
});
it("should return text/css for .css files", () => {
expect(getMimeType("styles.css")).toBe("text/css");
});
it("should return text/javascript for .js files", () => {
expect(getMimeType("script.js")).toBe("text/javascript");
});
it("should return text/csv for .csv files", () => {
expect(getMimeType("data.csv")).toBe("text/csv");
});
it("should return application/xml for .xml files", () => {
expect(getMimeType("data.xml")).toBe("application/xml");
});
});
describe("Microsoft Office types", () => {
it("should return correct MIME type for .doc files", () => {
expect(getMimeType("document.doc")).toBe("application/msword");
});
it("should return correct MIME type for .docx files", () => {
expect(getMimeType("document.docx")).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
});
it("should return correct MIME type for .xls files", () => {
expect(getMimeType("spreadsheet.xls")).toBe("application/vnd.ms-excel");
});
it("should return correct MIME type for .xlsx files", () => {
expect(getMimeType("spreadsheet.xlsx")).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
it("should return correct MIME type for .ppt files", () => {
expect(getMimeType("presentation.ppt")).toBe(
"application/vnd.ms-powerpoint",
);
});
it("should return correct MIME type for .pptx files", () => {
expect(getMimeType("presentation.pptx")).toBe(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
);
});
});
describe("font types", () => {
it("should return font/ttf for .ttf files", () => {
expect(getMimeType("font.ttf")).toBe("font/ttf");
});
it("should return font/otf for .otf files", () => {
expect(getMimeType("font.otf")).toBe("font/otf");
});
it("should return font/woff for .woff files", () => {
expect(getMimeType("font.woff")).toBe("font/woff");
});
it("should return font/woff2 for .woff2 files", () => {
expect(getMimeType("font.woff2")).toBe("font/woff2");
});
it("should return application/vnd.ms-fontobject for .eot files", () => {
expect(getMimeType("font.eot")).toBe("application/vnd.ms-fontobject");
});
});
describe("archive types", () => {
it("should return application/zip for .zip files", () => {
expect(getMimeType("archive.zip")).toBe("application/zip");
});
it("should return application/gzip for .gz files", () => {
expect(getMimeType("archive.gz")).toBe("application/gzip");
});
it("should return application/x-tar for .tar files", () => {
expect(getMimeType("archive.tar")).toBe("application/x-tar");
});
it("should return application/x-rar-compressed for .rar files", () => {
expect(getMimeType("archive.rar")).toBe("application/x-rar-compressed");
});
it("should return application/x-7z-compressed for .7z files", () => {
expect(getMimeType("archive.7z")).toBe("application/x-7z-compressed");
});
});
describe("case insensitivity", () => {
it("should handle uppercase extensions", () => {
expect(getMimeType("IMAGE.PNG")).toBe("image/png");
expect(getMimeType("VIDEO.MP4")).toBe("video/mp4");
});
it("should handle mixed case extensions", () => {
expect(getMimeType("file.JpG")).toBe("image/jpeg");
expect(getMimeType("file.Mp3")).toBe("audio/mpeg");
});
});
describe("unknown types", () => {
it("should return undefined for unknown extensions", () => {
expect(getMimeType("file.xyz")).toBeUndefined();
expect(getMimeType("file.unknown")).toBeUndefined();
});
it("should return undefined for files without extensions", () => {
expect(getMimeType("filename")).toBeUndefined();
});
});
describe("path handling", () => {
it("should handle full paths", () => {
expect(getMimeType("/home/user/documents/image.png")).toBe("image/png");
expect(getMimeType("./relative/path/video.mp4")).toBe("video/mp4");
});
it("should handle filenames with multiple dots", () => {
expect(getMimeType("file.name.with.dots.png")).toBe("image/png");
expect(getMimeType("archive.tar.gz")).toBe("application/gzip");
});
});
});
@@ -0,0 +1,258 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { paginatedFetch } from "../paginatedFetch.js";
type TestItem = Record<string, unknown>;
describe("paginatedFetch", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
vi.spyOn(console, "error").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch a single page when results fit in one request", async() => {
const mockData = [
{ id: 1, name: "item1" },
{ id: 2, name: "item2" },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api?limit=10&page=1&offset=0",
{},
);
});
it("should handle multiple pages", async() => {
const page1 = [
{ id: 1 },
{ id: 2 },
];
const page2 = [
{ id: 3 },
{ id: 4 },
];
const page3: Array<TestItem> = [];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page1);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page2);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page3);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
2,
);
expect(result).toEqual([ ...page1, ...page2 ]);
expect(global.fetch).toHaveBeenCalledTimes(3);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://example.com/api?limit=2&page=1&offset=0",
{},
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://example.com/api?limit=2&page=2&offset=2",
{},
);
expect(global.fetch).toHaveBeenNthCalledWith(
3,
"https://example.com/api?limit=2&page=3&offset=4",
{},
);
});
it("should pass options to fetch", async() => {
const options: RequestInit = {
headers: { authorization: "Bearer token" },
};
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
options,
);
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
options,
);
});
it("should throw on first page error", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
await expect(
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
).rejects.toThrow(
"Failed to fetch https://example.com/api?limit=10&page=1&offset=0: 500 Internal Server Error",
);
});
it("should throw if first page is not an array", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: "not an array" });
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
await expect(
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
).rejects.toThrow("Expected array response but got object");
});
it("should stop pagination on subsequent page error", async() => {
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([{ id: 1 }]);
expect(console.error).toHaveBeenCalled();
});
it("should stop pagination if subsequent page is not an array", async() => {
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ notAnArray: true });
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([{ id: 1 }]);
expect(console.error).toHaveBeenCalled();
});
it("should handle empty first page", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,128 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect } from "vitest";
import { serialiseJsonOrError } from "../serialiseJsonOrError.js";
describe("serialiseJsonOrError", () => {
describe("valid JSON parsing", () => {
it("should parse a simple object", () => {
const result = serialiseJsonOrError('{"key": "value"}');
expect(result).toEqual({ key: "value" });
});
it("should parse an object with multiple properties", () => {
const result = serialiseJsonOrError(
'{"name": "test", "count": 42, "active": true}',
);
expect(result).toEqual({ name: "test", count: 42, active: true });
});
it("should parse nested objects", () => {
const result = serialiseJsonOrError(
'{"outer": {"inner": {"deep": "value"}}}',
);
expect(result).toEqual({ outer: { inner: { deep: "value" } } });
});
it("should parse objects with arrays", () => {
const result = serialiseJsonOrError('{"items": [1, 2, 3]}');
expect(result).toEqual({ items: [ 1, 2, 3 ] });
});
it("should parse objects with null values", () => {
const result = serialiseJsonOrError('{"value": null}');
expect(result).toEqual({ value: null });
});
it("should parse an empty object", () => {
const result = serialiseJsonOrError("{}");
expect(result).toEqual({});
});
it("should parse objects with special characters in strings", () => {
const result = serialiseJsonOrError('{"text": "hello\\nworld"}');
expect(result).toEqual({ text: "hello\nworld" });
});
it("should parse objects with unicode characters", () => {
const result = serialiseJsonOrError('{"emoji": "🎉", "text": "日本語"}');
expect(result).toEqual({ emoji: "🎉", text: "日本語" });
});
it("should parse objects with numeric keys (as strings)", () => {
const result = serialiseJsonOrError('{"123": "numeric key"}');
expect(result).toEqual({ "123": "numeric key" });
});
});
describe("invalid JSON handling", () => {
it("should return null for invalid JSON syntax", () => {
expect(serialiseJsonOrError("{invalid}")).toBeNull();
expect(serialiseJsonOrError("not json")).toBeNull();
});
it("should return null for unclosed braces", () => {
expect(serialiseJsonOrError('{"key": "value"')).toBeNull();
expect(serialiseJsonOrError('{"key": {')).toBeNull();
});
it("should return null for trailing commas", () => {
expect(serialiseJsonOrError('{"key": "value",}')).toBeNull();
});
it("should return null for single quotes", () => {
expect(serialiseJsonOrError("{'key': 'value'}")).toBeNull();
});
it("should return null for unquoted keys", () => {
expect(serialiseJsonOrError('{key: "value"}')).toBeNull();
});
it("should return null for empty string", () => {
expect(serialiseJsonOrError("")).toBeNull();
});
it("should return null for whitespace only", () => {
expect(serialiseJsonOrError(" ")).toBeNull();
});
});
describe("edge cases", () => {
it("should parse a plain array (returns as object)", () => {
const result = serialiseJsonOrError("[1, 2, 3]");
expect(result).toEqual([ 1, 2, 3 ]);
});
it("should parse primitive values", () => {
expect(serialiseJsonOrError("42")).toBe(42);
expect(serialiseJsonOrError('"string"')).toBe("string");
expect(serialiseJsonOrError("true")).toBe(true);
expect(serialiseJsonOrError("false")).toBe(false);
expect(serialiseJsonOrError("null")).toBeNull();
});
it("should handle very large numbers", () => {
const result = serialiseJsonOrError('{"big": 9007199254740991}');
expect(result).toEqual({ big: 9007199254740991 });
});
it("should handle scientific notation", () => {
const result = serialiseJsonOrError('{"sci": 1.23e10}');
expect(result).toEqual({ sci: 1.23e10 });
});
it("should handle deeply nested structures", () => {
const deepJson = '{"a":{"b":{"c":{"d":{"e":"deep"}}}}}';
const result = serialiseJsonOrError(deepJson);
expect(result).toEqual({ a: { b: { c: { d: { e: "deep" } } } } });
});
it("should handle JSON with whitespace", () => {
const result = serialiseJsonOrError(' { "key" : "value" } ');
expect(result).toEqual({ key: "value" });
});
});
});
@@ -0,0 +1,81 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sleep } from "../sleep.js";
describe("sleep", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should resolve after the specified time", async() => {
const sleepPromise = sleep(1000);
vi.advanceTimersByTime(1000);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should not resolve before the specified time", async() => {
let resolved = false;
const sleepPromise = sleep(1000).then(() => {
resolved = true;
});
vi.advanceTimersByTime(999);
await Promise.resolve();
expect(resolved).toBe(false);
vi.advanceTimersByTime(1);
await sleepPromise;
expect(resolved).toBe(true);
});
it("should handle zero milliseconds", async() => {
const sleepPromise = sleep(0);
vi.advanceTimersByTime(0);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should handle small delays", async() => {
const sleepPromise = sleep(10);
vi.advanceTimersByTime(10);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should handle large delays", async() => {
const sleepPromise = sleep(60000);
vi.advanceTimersByTime(60000);
await expect(sleepPromise).resolves.toBeUndefined();
});
describe("real timer tests", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("should actually wait with real timers (short delay)", async() => {
const start = Date.now();
await sleep(50);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45);
expect(elapsed).toBeLessThan(150);
});
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
exclude: ["node_modules/", "**/__tests__/**"],
},
},
});