generated from nhcarrigan/template
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c829ec97c4
|
|||
|
dac875c413
|
@@ -15,3 +15,9 @@ Thumbs.db
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.lcov
|
||||
@@ -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)
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
Executable
+225
@@ -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
|
||||
Executable
+298
@@ -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 "$@"
|
||||
Executable
+202
@@ -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 "$@"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 15–23)**\n```\n{table}\n```"
|
||||
status = "OK" if len(msg) <= 2000 else f"OVER by {len(msg) - 2000}"
|
||||
print(f"{team}: {len(msg)} chars — {status}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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!"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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 15–23)**\n```\n{table}\n```"
|
||||
)
|
||||
|
||||
if len(message) > 2000:
|
||||
print(
|
||||
f" [{i}/{len(team_names)}] {team_name} — WARNING: "
|
||||
f"message is {len(message)} chars (over 2000!)"
|
||||
)
|
||||
|
||||
print(
|
||||
f" [{i}/{len(team_names)}] Sending to {team_name}... ",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
await send_message(session, channel_id, message)
|
||||
print("sent!")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
print("\nAll done! 🌸")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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_*"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Test package for ephemere Python scripts
|
||||
@@ -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"]
|
||||
@@ -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=$?
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+332
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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__/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user