generated from nhcarrigan/template
feat: reorganise bash scripts and add comprehensive documentation (#6)
CI / dependency-pin-check-typescript (push) Successful in 5s
CI / dependency-pin-check-python (push) Successful in 4s
CI / python (push) Successful in 9m28s
CI / typescript (push) Successful in 9m42s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
CI / dependency-pin-check-typescript (push) Successful in 5s
CI / dependency-pin-check-python (push) Successful in 4s
CI / python (push) Successful in 9m28s
CI / typescript (push) Successful in 9m42s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
## Summary This PR completes the bash script restructuring and adds comprehensive documentation across all script categories. ### Bash Restructuring - Moved cohort shell scripts (`remove_github_members.sh`, `update_github_teams.sh`) from `python/cohort/` into a new `bash/cohort/` directory - Moved existing bash utilities (`add-keys-to-git.sh`, `fix-yubikey-perms.sh`, `list-yubikey-ssh-keys.sh`) into a new `bash/yubikey/` subdirectory - Updated `run.sh` to support **Bash** as a third language option alongside TypeScript and Python - Bash scripts are run directly (no 1Password secret injection needed) - Category discovery and script listing works the same as for TS/Python - Removed dead "Root Scripts" logic that was no longer needed ### Documentation Added `README.md` files for all script categories that were missing them: - `bash/cohort/README.md` — cohort GitHub team management scripts - `bash/yubikey/README.md` — YubiKey SSH key and permission utilities - `typescript/src/crowdin/README.md` — Crowdin translation management scripts - `typescript/src/discord/README.md` — Discord bot utility scripts - `typescript/src/discourse/README.md` — Discourse forum management scripts - `typescript/src/gitea/README.md` — Gitea bulk repository operation scripts - `typescript/src/github/README.md` — GitHub API interaction scripts - `typescript/src/music/README.md` — Music file metadata tools - `typescript/src/s3/README.md` — S3-compatible object storage scripts - `typescript/src/security/README.md` — Security analysis and reporting scripts - `python/cohort/README.md` — Updated to remove moved shell scripts, fix usage commands Also updated project-level docs: - **`README.md`** — Corrected project structure, fixed running instructions (removed references to non-existent `make run-ts`/`make run-py` targets), added Bash prerequisites - **`CLAUDE.md`** — Updated project overview, structure, development standards, and script-adding guides to reflect the current state of the project ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #6 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
# Cohort Scripts
|
||||
|
||||
Scripts for managing the NHCarrigan spring cohort programme. Covers the full lifecycle: applicant evaluation, Discord server setup, team assignment, member onboarding, activity tracking, and member removal.
|
||||
|
||||
Most scripts interact with the Discord API and require a `DISCORD_BOT_TOKEN`. Several also use the `gh` CLI for GitHub operations.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Run scripts via the interactive runner from the project root:
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → <script>
|
||||
```
|
||||
|
||||
Or run directly:
|
||||
|
||||
```bash
|
||||
cd python && op run --env-file=../prod.env -- uv run python cohort/<script>.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Run `make install-py` to set up the Python virtual environment.
|
||||
- Most scripts require `DISCORD_BOT_TOKEN` set in `prod.env`.
|
||||
- Scripts that manage GitHub teams require `gh auth login` to be run first.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
### Applicant Evaluation
|
||||
- [verify_discord.py](#verify_discordpy)
|
||||
- [evaluate_technical_proficiency.py](#evaluate_technical_proficiencypy)
|
||||
- [analyse_availability.py](#analyse_availabilitypy)
|
||||
- [generate_member_files.py](#generate_member_filespy)
|
||||
- [generate_timeslots.py](#generate_timeslotspy)
|
||||
|
||||
### Server Setup
|
||||
- [create_team_voice_channels.py](#create_team_voice_channelspy)
|
||||
- [fix_channel_permissions.py](#fix_channel_permissionspy)
|
||||
- [update_cohort_leads_permissions.py](#update_cohort_leads_permissionspy)
|
||||
- [list_all_guild_roles.py](#list_all_guild_rolespy)
|
||||
- [list_discord_roles.py](#list_discord_rolespy)
|
||||
- [check_channel_permissions.py](#check_channel_permissionspy)
|
||||
|
||||
### Member Onboarding
|
||||
- [send_team_messages.py](#send_team_messagespy)
|
||||
- [assign_cohort_role.py](#assign_cohort_rolepy)
|
||||
- [assign_team_roles.py](#assign_team_rolespy)
|
||||
- [add_github_team_members.py](#add_github_team_memberspy)
|
||||
|
||||
### Ongoing Management
|
||||
- [get_cohort_members.py](#get_cohort_memberspy)
|
||||
- [check_member_status.py](#check_member_statuspy)
|
||||
- [fetch_roster.py](#fetch_rosterpy)
|
||||
- [update_roster_messages.py](#update_roster_messagespy)
|
||||
- [send_checkin.py](#send_checkinpy)
|
||||
- [send_team_checkin.py](#send_team_checkinpy)
|
||||
- [send_team_messages.py](#send_team_messagespy)
|
||||
|
||||
### Activity Tracking
|
||||
- [discord_activity_checker.py](#discord_activity_checkerpy)
|
||||
- [catch_up_report.py](#catch_up_reportpy)
|
||||
- [check_lengths.py](#check_lengthspy)
|
||||
- [send_activity_report.py](#send_activity_reportpy)
|
||||
|
||||
### Member Removal
|
||||
- [remove_member.py](#remove_memberpy)
|
||||
- [remove_resigned_members.py](#remove_resigned_memberspy)
|
||||
- [remove_discord_roles.py](#remove_discord_rolespy)
|
||||
|
||||
---
|
||||
|
||||
## verify_discord.py
|
||||
|
||||
Reads Discord user IDs from a markdown table and verifies each one against the Discord API, checking whether the user is a member of the freeCodeCamp guild. Handles rate limits and retries automatically.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → verify_discord.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `table.md` | Markdown table | Applicant data; the script reads Discord IDs from the table rows |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `discord_verification.json` | JSON object | Results grouped as `verified` (with username), `missing`, and `errors` lists |
|
||||
|
||||
---
|
||||
|
||||
## evaluate_technical_proficiency.py
|
||||
|
||||
Evaluates each applicant's technical proficiency by analysing their GitHub profile and self-described expertise. Scores applicants as Beginner, Intermediate, or Advanced based on public repository count, follower count, language variety, and keywords in their self-assessment text.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → evaluate_technical_proficiency.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None. Uses the public GitHub API (unauthenticated, subject to rate limiting).
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `applicants_to_evaluate.json` | JSON array | Applicant records, each with a GitHub profile URL and a self-description field |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `proficiency_evaluations.json` | JSON array | Proficiency scores (`Beginner`/`Intermediate`/`Advanced`) and detected tech stacks per applicant |
|
||||
|
||||
### Notes
|
||||
|
||||
- Unauthenticated GitHub API requests are rate-limited to 60 per hour. For large batches of applicants, consider adding a GitHub token or running the script in smaller chunks.
|
||||
|
||||
---
|
||||
|
||||
## analyse_availability.py
|
||||
|
||||
Parses a markdown table of applicant availability responses with timezone information, converts local timeslot selections to UTC, and produces a JSON summary of UTC coverage across morning, afternoon, evening, and night blocks per applicant.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → analyse_availability.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None.
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `table.md` | Markdown table | Availability responses with timezone column |
|
||||
| `discord_verification.json` | JSON object | Output of `verify_discord.py` — used to link applicants to Discord usernames |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `availability_analysis.json` | JSON object | UTC block distribution (morning/afternoon/evening/night) per applicant |
|
||||
|
||||
---
|
||||
|
||||
## generate_member_files.py
|
||||
|
||||
Consolidates all evaluation data into two markdown files: one for participants and one for team leaders. Each entry includes the member's tech stack, availability, proficiency level, and (for leaders) their leadership assessment.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → generate_member_files.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None.
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `discord_verification.json` | JSON object | Verified applicant Discord info |
|
||||
| `proficiency_evaluations.json` | JSON array | Technical proficiency scores |
|
||||
| `availability_analysis.json` | JSON object | UTC availability blocks |
|
||||
| `leadership_candidates.json` | JSON array | Applicants being considered for leadership roles |
|
||||
| `leadership_evaluations.json` | JSON array | Leadership assessment results |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `participants.md` | Markdown | Profile summary for each participant |
|
||||
| `leaders.md` | Markdown | Profile summary for each team leader candidate |
|
||||
|
||||
---
|
||||
|
||||
## generate_timeslots.py
|
||||
|
||||
Generates a list of hourly timeslots for use with the [Crabfit](https://crab.fit/) scheduling tool, covering a date range at Los Angeles timezone.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → generate_timeslots.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None.
|
||||
|
||||
### Data Files
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `crabfit_timeslots.json` | JSON array of ISO-format strings | One entry per hour across the configured date range |
|
||||
|
||||
### Notes
|
||||
|
||||
- The date range and timezone are hardcoded in the script. Update the start/end date constants before running.
|
||||
|
||||
---
|
||||
|
||||
## create_team_voice_channels.py
|
||||
|
||||
Creates private voice channels for each cohort team in a specified Discord category. Each channel is visible and joinable only by members who have that team's role.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → create_team_voice_channels.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Team role IDs and the target category ID are hardcoded in the script.
|
||||
|
||||
### Notes
|
||||
|
||||
- Update the team role IDs, channel names, and category ID constants in the script before running.
|
||||
|
||||
---
|
||||
|
||||
## fix_channel_permissions.py
|
||||
|
||||
Denies `Send Messages` and `Send Messages in Threads` permissions for the `@everyone` and `@cohort` roles on a specific Discord channel. Used to lock down a channel so only designated roles can post.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → fix_channel_permissions.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. The target channel ID is hardcoded in the script.
|
||||
|
||||
### Notes
|
||||
|
||||
- Update the `CHANNEL_ID` constant before running.
|
||||
|
||||
---
|
||||
|
||||
## update_cohort_leads_permissions.py
|
||||
|
||||
Grants the Cohort Leads role `MENTION_EVERYONE` and `PIN_MESSAGES` permissions across all 14 team channels.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → update_cohort_leads_permissions.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Team channel IDs are hardcoded in the script.
|
||||
|
||||
---
|
||||
|
||||
## list_all_guild_roles.py
|
||||
|
||||
Lists all roles in the freeCodeCamp Discord guild, highlighting team and cohort-related roles. Useful for finding role IDs to use in other scripts.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → list_all_guild_roles.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Output is printed to stdout.
|
||||
|
||||
---
|
||||
|
||||
## list_discord_roles.py
|
||||
|
||||
Lists Discord roles in the freeCodeCamp server, filtering for cohort-, leader-, and 2026-related roles.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → list_discord_roles.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Output is printed to stdout.
|
||||
|
||||
---
|
||||
|
||||
## check_channel_permissions.py
|
||||
|
||||
Audits `cohort-team-*` Discord channels to identify incorrect `Send Messages` or `Send Messages in Threads` permissions on the `@everyone` or `@cohort` roles.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → check_channel_permissions.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Results are printed to stdout.
|
||||
|
||||
---
|
||||
|
||||
## send_team_messages.py
|
||||
|
||||
Sends initial welcome and roster messages to all 14 team Discord channels, pins them, and saves the resulting message IDs, channel IDs, and role IDs to `data/team_message_ids.json`. This file is required by several other scripts.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → send_team_messages.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Team rosters (leaders and participants per team) |
|
||||
| `applicants_to_evaluate.json` | JSON array | Applicant data used to populate roster messages |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_message_ids.json` | JSON object | Channel ID, pinned message ID, and role ID per team name |
|
||||
|
||||
### Notes
|
||||
|
||||
- Run this **once** at the start of the cohort. Other scripts (`send_checkin.py`, `update_roster_messages.py`, etc.) depend on `team_message_ids.json`.
|
||||
|
||||
---
|
||||
|
||||
## assign_cohort_role.py
|
||||
|
||||
Assigns the Cohort Discord role to all cohort participants. Reads the full list of unique member Discord IDs from `team_assignments.json` and assigns the role to each one, with exponential backoff to handle rate limits.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → assign_cohort_role.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Team rosters; all unique user IDs are extracted from here |
|
||||
|
||||
---
|
||||
|
||||
## assign_team_roles.py
|
||||
|
||||
Assigns team-specific Discord roles to all cohort participants based on their team membership in `team_assignments.json`. Uses exponential backoff for rate limit handling.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → assign_team_roles.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Team rosters with Discord IDs and corresponding role IDs |
|
||||
|
||||
---
|
||||
|
||||
## add_github_team_members.py
|
||||
|
||||
Adds cohort members to their corresponding GitHub teams in the `nhcarrigan-spring-2026-cohort` organisation. Leaders are added to both the main team and the corresponding `-leaders` sub-team; participants are added to the main team only.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → add_github_team_members.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None. Uses the `gh` CLI for authentication (run `gh auth login` first).
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Team rosters with leader/participant distinction |
|
||||
| `discord_to_github.json` | JSON object | Maps Discord user IDs to GitHub usernames |
|
||||
|
||||
---
|
||||
|
||||
## get_cohort_members.py
|
||||
|
||||
Fetches all Discord guild members with the Cohort role and writes their IDs, usernames, and display names to a JSON file. Also prints a formatted list to stdout.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → get_cohort_members.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `active_cohort_members.json` | JSON array | Objects with `id`, `username`, and `display_name` for each Cohort role member |
|
||||
|
||||
---
|
||||
|
||||
## check_member_status.py
|
||||
|
||||
Verifies whether specific Discord member IDs are still in the freeCodeCamp guild by querying the Discord API. Used to confirm that removed members have been successfully ejected.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → check_member_status.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. The member IDs to check are hardcoded in the script. Update the list before running.
|
||||
|
||||
---
|
||||
|
||||
## fetch_roster.py
|
||||
|
||||
Fetches pinned messages from a specific team channel to retrieve the current roster.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → fetch_roster.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Output is printed to stdout as JSON.
|
||||
|
||||
### Notes
|
||||
|
||||
- The target channel ID is hardcoded. Update it before running.
|
||||
|
||||
---
|
||||
|
||||
## update_roster_messages.py
|
||||
|
||||
Edits the pinned team roster messages in each Discord channel with the latest data from `team_assignments.json` and `discord_to_github.json`. Run this after any membership changes to keep the pinned rosters up to date.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → update_roster_messages.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_message_ids.json` | JSON object | Channel and message IDs per team (output of `send_team_messages.py`) |
|
||||
| `team_assignments.json` | JSON object | Current team rosters |
|
||||
| `discord_to_github.json` | JSON object | Discord ID → GitHub username mapping |
|
||||
|
||||
---
|
||||
|
||||
## send_checkin.py
|
||||
|
||||
Sends biweekly check-in prompts to all team Discord channels (except Jade Jasmine), automatically creating threads for responses. Members who do not respond face removal for inactivity.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → send_checkin.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_message_ids.json` | JSON object | Channel IDs per team |
|
||||
|
||||
---
|
||||
|
||||
## send_team_checkin.py
|
||||
|
||||
Sends a capacity check-in message to each team channel asking whether the team feels able to complete their project with their current member count, and inviting them to request support if needed.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → send_team_checkin.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_message_ids.json` | JSON object | Channel IDs per team |
|
||||
| `team_assignments.json` | JSON object | Current team rosters (used to mention team members) |
|
||||
|
||||
---
|
||||
|
||||
## discord_activity_checker.py
|
||||
|
||||
Scans each team's Discord channel and threads to identify members who have not sent a message within the last 36 hours. Can optionally send notification messages directly to inactive members via the `--send` CLI flag.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Check only (no messages sent)
|
||||
make run
|
||||
# Select: Python → cohort → discord_activity_checker.py
|
||||
|
||||
# Check and notify inactive members
|
||||
cd python && op run --env-file=../prod.env -- uv run python cohort/discord_activity_checker.py --send
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Member lists per team |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `discord_activity_report.json` | JSON object | Inactive members per team with their last message timestamp |
|
||||
|
||||
---
|
||||
|
||||
## catch_up_report.py
|
||||
|
||||
Generates a detailed markdown activity report covering Discord messages (in team channels and their threads) and GitHub activity (PRs, issues, comments, reviews, commits) since a configured start date. Uses async API calls for efficiency.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → catch_up_report.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Team rosters |
|
||||
| `discord_to_github.json` | JSON object | Discord ID → GitHub username mapping |
|
||||
|
||||
**Output** (written to `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `catch_up_report.md` | Markdown table | Activity counts per member (Discord messages + GitHub contributions) per team |
|
||||
|
||||
### Notes
|
||||
|
||||
- The report start date is hardcoded in the script. Update it before each use.
|
||||
- GitHub activity is fetched via the unauthenticated public API; rate limiting may apply for large cohorts.
|
||||
|
||||
---
|
||||
|
||||
## check_lengths.py
|
||||
|
||||
Dry-run validation that parses `catch_up_report.md` and formats each team's data into Discord monospace table strings, checking whether any would exceed Discord's 2,000-character message limit before actually sending them.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → check_lengths.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None.
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `catch_up_report.md` | Markdown table | Output of `catch_up_report.py` |
|
||||
|
||||
### Notes
|
||||
|
||||
- Run this after `catch_up_report.py` and before `send_activity_report.py` to catch any messages that would be rejected by Discord.
|
||||
|
||||
---
|
||||
|
||||
## send_activity_report.py
|
||||
|
||||
Parses `catch_up_report.md` and sends the formatted activity table for each team to its Discord channel as a monospace code block.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → send_activity_report.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input** (expected in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `catch_up_report.md` | Markdown table | Output of `catch_up_report.py` |
|
||||
|
||||
### Notes
|
||||
|
||||
- Run `check_lengths.py` first to verify that no messages exceed Discord's character limit.
|
||||
|
||||
---
|
||||
|
||||
## remove_member.py
|
||||
|
||||
Comprehensive member removal script. Given a Discord ID as a CLI argument, it:
|
||||
|
||||
1. Removes the member from `team_assignments.json`.
|
||||
2. Removes the member from `discord_to_github.json`.
|
||||
3. Prints GitHub removal instructions.
|
||||
4. Removes the member's Discord Cohort and team roles.
|
||||
5. Sends an announcement message to the team's Discord channel.
|
||||
6. Outputs markdown notes suitable for pasting into `COHORT_NOTES.md`.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
cd python && op run --env-file=../prod.env -- uv run python cohort/remove_member.py <discord_id>
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input/Output** (read and updated in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Updated: member removed from their team |
|
||||
| `discord_to_github.json` | JSON object | Updated: Discord→GitHub mapping removed |
|
||||
| `team_message_ids.json` | JSON object | Read: used to find the team's Discord channel ID |
|
||||
|
||||
---
|
||||
|
||||
## remove_resigned_members.py
|
||||
|
||||
Removes a list of resigned member Discord IDs from `team_assignments.json`, updating team rosters and reporting which teams were affected. Does not interact with Discord or GitHub.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → remove_resigned_members.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None.
|
||||
|
||||
### Data Files
|
||||
|
||||
**Input/Output** (read and updated in `data/`):
|
||||
|
||||
| File | Format | Description |
|
||||
|---|---|---|
|
||||
| `team_assignments.json` | JSON object | Updated in place; affected teams are reported to stdout |
|
||||
|
||||
### Notes
|
||||
|
||||
- The list of Discord IDs to remove is hardcoded in the script. Update it before running.
|
||||
- This script only updates the local JSON file. Run `update_roster_messages.py` and handle GitHub/Discord role removal separately.
|
||||
|
||||
---
|
||||
|
||||
## remove_discord_roles.py
|
||||
|
||||
Removes the Cohort and team-specific Discord roles from a hardcoded list of inactive members.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
make run
|
||||
# Select: Python → cohort → remove_discord_roles.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
|
||||
### Data Files
|
||||
|
||||
None. Member IDs and their team-to-role mappings are hardcoded in the script. Update them before running.
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add GitHub users to their appropriate teams in nhcarrigan-spring-2026-cohort org"""
|
||||
"""Add GitHub users to their appropriate teams in the cohort GitHub organisation.
|
||||
|
||||
Uses the GitHub CLI to add each member to their corresponding team and, for
|
||||
leaders, to the team's leaders sub-team.
|
||||
|
||||
Data files (place in data/):
|
||||
- team_assignments.json Team rosters with leaders and participants per team
|
||||
- discord_to_github.json Mapping of Discord IDs to GitHub usernames
|
||||
|
||||
Env vars:
|
||||
- None (uses `gh` CLI for authentication)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
# Load team assignments and Discord to GitHub mappings
|
||||
with open("team_assignments.json") as f:
|
||||
with open(DATA_DIR / "team_assignments.json") as f:
|
||||
teams = json.load(f)
|
||||
|
||||
with open("discord_to_github.json") as f:
|
||||
with open(DATA_DIR / "discord_to_github.json") as f:
|
||||
discord_to_github = json.load(f)
|
||||
|
||||
# Map team names to GitHub team slugs
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
"""Analyse applicant availability from a markdown table and produce UTC block stats.
|
||||
|
||||
Reads a markdown table of availability responses and a Discord verification file,
|
||||
then produces a JSON summary of coverage across morning/afternoon/evening UTC blocks
|
||||
for each day of the week.
|
||||
|
||||
Data files (place in data/):
|
||||
- table.md Markdown table of applicant availability responses
|
||||
- discord_verification.json Discord ID verification results (from verify_discord.py)
|
||||
|
||||
Outputs (written to data/):
|
||||
- availability_analysis.json UTC block distribution per applicant
|
||||
|
||||
Env vars:
|
||||
- None
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
@@ -99,7 +119,7 @@ def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict:
|
||||
|
||||
def parse_table_md() -> list[dict]:
|
||||
"""Parse table.md and extract availability data"""
|
||||
with open("table.md") as f:
|
||||
with open(DATA_DIR / "table.md") as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.strip().split("\n")
|
||||
@@ -131,7 +151,7 @@ def parse_table_md() -> list[dict]:
|
||||
|
||||
|
||||
def main():
|
||||
with open("discord_verification.json") as f:
|
||||
with open(DATA_DIR / "discord_verification.json") as f:
|
||||
verification = json.load(f)
|
||||
|
||||
verified_ids = {v[0] for v in verification["verified"]}
|
||||
@@ -167,7 +187,7 @@ def main():
|
||||
}
|
||||
)
|
||||
|
||||
with open("availability_analysis.json", "w") as f:
|
||||
with open(DATA_DIR / "availability_analysis.json", "w") as f:
|
||||
json.dump(availability_results, f, indent=2)
|
||||
|
||||
block_distribution = defaultdict(int)
|
||||
|
||||
@@ -6,9 +6,12 @@ Respects Discord rate limits with proper backoff and retry logic.
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
GUILD_ID = "692816967895220344"
|
||||
COHORT_ROLE_ID = "1464314780935258112"
|
||||
@@ -48,7 +51,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) ->
|
||||
|
||||
|
||||
def main():
|
||||
with open("team_assignments.json") as f:
|
||||
with open(DATA_DIR / "team_assignments.json") as f:
|
||||
teams = json.load(f)
|
||||
|
||||
all_users = []
|
||||
|
||||
@@ -6,9 +6,12 @@ Respects Discord rate limits with proper backoff and retry logic.
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
GUILD_ID = "692816967895220344"
|
||||
|
||||
@@ -64,7 +67,7 @@ def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) ->
|
||||
|
||||
|
||||
def main():
|
||||
with open("team_assignments.json") as f:
|
||||
with open(DATA_DIR / "team_assignments.json") as f:
|
||||
teams = json.load(f)
|
||||
|
||||
print(f"Assigning team roles to {len(teams)} teams...")
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check cohort-team-* channels for incorrect @everyone or @cohort permissions.
|
||||
|
||||
Find channels where @everyone or @cohort has Send Messages or
|
||||
Send Messages in Threads enabled.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
BASE_URL = "https://discord.com/api/v10"
|
||||
GUILD_ID = "739845668582981683"
|
||||
|
||||
SEND_MESSAGES = 0x0000000000000800
|
||||
SEND_MESSAGES_IN_THREADS = 0x0000004000000000
|
||||
|
||||
|
||||
async def check_permissions() -> None:
|
||||
"""Check all cohort-team-* channels for permission issues."""
|
||||
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
print("Fetching channels...")
|
||||
async with session.get(
|
||||
f"{BASE_URL}/guilds/{GUILD_ID}/channels", headers=headers
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
error = await resp.text()
|
||||
print(f"Error fetching channels: {resp.status} - {error}")
|
||||
return
|
||||
channels = await resp.json()
|
||||
|
||||
print("Fetching roles...")
|
||||
async with session.get(
|
||||
f"{BASE_URL}/guilds/{GUILD_ID}/roles", headers=headers
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
error = await resp.text()
|
||||
print(f"Error fetching roles: {resp.status} - {error}")
|
||||
return
|
||||
roles = await resp.json()
|
||||
|
||||
everyone_role_id = GUILD_ID
|
||||
|
||||
cohort_role_id = None
|
||||
for role in roles:
|
||||
if "cohort" in role["name"].lower():
|
||||
cohort_role_id = role["id"]
|
||||
print(f"Found cohort role: {role['name']} ({role['id']})")
|
||||
break
|
||||
|
||||
if not cohort_role_id:
|
||||
print("Warning: Could not find @cohort role!")
|
||||
|
||||
cohort_channels = [
|
||||
ch
|
||||
for ch in channels
|
||||
if ch["name"].startswith("cohort-team-") and ch["type"] == 0
|
||||
]
|
||||
|
||||
print(f"\nFound {len(cohort_channels)} cohort-team-* channels\n")
|
||||
|
||||
problematic_channels = []
|
||||
|
||||
for channel in sorted(cohort_channels, key=lambda x: x["name"]):
|
||||
channel_name = channel["name"]
|
||||
channel_id = channel["id"]
|
||||
permission_overwrites = channel.get("permission_overwrites", [])
|
||||
|
||||
everyone_perms = None
|
||||
cohort_perms = None
|
||||
|
||||
for overwrite in permission_overwrites:
|
||||
if overwrite["id"] == everyone_role_id:
|
||||
everyone_perms = overwrite
|
||||
elif cohort_role_id and overwrite["id"] == cohort_role_id:
|
||||
cohort_perms = overwrite
|
||||
|
||||
issues = []
|
||||
|
||||
if everyone_perms:
|
||||
deny = int(everyone_perms.get("deny", "0"))
|
||||
allow = int(everyone_perms.get("allow", "0"))
|
||||
|
||||
if (allow & SEND_MESSAGES) or not (deny & SEND_MESSAGES):
|
||||
issues.append("@everyone can send messages")
|
||||
|
||||
if (allow & SEND_MESSAGES_IN_THREADS) or not (
|
||||
deny & SEND_MESSAGES_IN_THREADS
|
||||
):
|
||||
issues.append("@everyone can send messages in threads")
|
||||
else:
|
||||
issues.append(
|
||||
"@everyone has no permission overwrite (inheriting server perms)"
|
||||
)
|
||||
|
||||
if cohort_perms and cohort_role_id:
|
||||
deny = int(cohort_perms.get("deny", "0"))
|
||||
allow = int(cohort_perms.get("allow", "0"))
|
||||
|
||||
if (allow & SEND_MESSAGES) or not (deny & SEND_MESSAGES):
|
||||
issues.append("@cohort can send messages")
|
||||
|
||||
if (allow & SEND_MESSAGES_IN_THREADS) or not (
|
||||
deny & SEND_MESSAGES_IN_THREADS
|
||||
):
|
||||
issues.append("@cohort can send messages in threads")
|
||||
elif cohort_role_id:
|
||||
issues.append(
|
||||
"@cohort has no permission overwrite (inheriting server perms)"
|
||||
)
|
||||
|
||||
if issues:
|
||||
problematic_channels.append(
|
||||
{"name": channel_name, "id": channel_id, "issues": issues}
|
||||
)
|
||||
print(f"❌ {channel_name}")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
else:
|
||||
print(f"✅ {channel_name}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(
|
||||
f"\nSummary: {len(problematic_channels)} channels with permission issues\n"
|
||||
)
|
||||
|
||||
if problematic_channels:
|
||||
print("Problematic channels:")
|
||||
for ch in problematic_channels:
|
||||
print(f"\n{ch['name']} (ID: {ch['id']})")
|
||||
for issue in ch["issues"]:
|
||||
print(f" • {issue}")
|
||||
else:
|
||||
print("All channels have correct permissions! 🎉")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_permissions())
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check if removed members are still in the Discord server."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
BASE_URL = "https://discord.com/api/v10"
|
||||
GUILD_ID = "1354624415861833870"
|
||||
|
||||
SAMPLE_MEMBERS = [
|
||||
"899092786802987069",
|
||||
"1318882254365397032",
|
||||
"799293680799711273",
|
||||
"237793557992308736",
|
||||
]
|
||||
|
||||
|
||||
async def check_member(session: aiohttp.ClientSession, user_id: str) -> bool | None:
|
||||
"""Check if a member is in the server."""
|
||||
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}"
|
||||
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
|
||||
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
roles = data.get("roles", [])
|
||||
print(f"✅ User {user_id} IS in server - has {len(roles)} roles: {roles}")
|
||||
return True
|
||||
if resp.status == 404:
|
||||
print(f"❌ User {user_id} NOT in server (left or was kicked)")
|
||||
return False
|
||||
error = await resp.text()
|
||||
print(f"⚠️ Error checking {user_id}: {resp.status} - {error}")
|
||||
return None
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Check if sample members are still in the server."""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for user_id in SAMPLE_MEMBERS:
|
||||
await check_member(session, user_id)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,6 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discord Team Activity Checker
|
||||
Checks for team members who haven't sent messages in their channels within 36 hours
|
||||
"""Check for team members who have not sent a message in their channel within 36 hours.
|
||||
|
||||
Scans each team's Discord channel and flags members with no recent activity.
|
||||
Optionally sends a direct mention message to inactive members.
|
||||
|
||||
Data files (place in data/):
|
||||
- team_assignments.json Team rosters with leaders and participants per team
|
||||
|
||||
Outputs (written to data/):
|
||||
- discord_activity_report.json Inactive members per team with last-seen timestamps
|
||||
|
||||
Env vars:
|
||||
- DISCORD_BOT_TOKEN Bot token for the Discord API
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -8,16 +19,19 @@ import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
# Configuration
|
||||
DISCORD_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
INACTIVE_THRESHOLD_HOURS = 36
|
||||
|
||||
# Load team assignments from file
|
||||
with open("team_assignments.json") as f:
|
||||
with open(DATA_DIR / "team_assignments.json") as f:
|
||||
team_data = json.load(f)
|
||||
|
||||
# Build TEAMS dictionary with channel IDs and member lists
|
||||
@@ -233,7 +247,7 @@ async def main():
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
# Save results to JSON
|
||||
with open("discord_activity_report.json", "w") as f:
|
||||
with open(DATA_DIR / "discord_activity_report.json", "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
"""Evaluate the technical proficiency of cohort applicants using their GitHub profiles.
|
||||
|
||||
Fetches each applicant's public GitHub repositories and scores their proficiency as
|
||||
Beginner, Intermediate, or Advanced based on language variety, repo count, commit
|
||||
activity, and presence of certain technologies.
|
||||
|
||||
Data files (place in data/):
|
||||
- applicants_to_evaluate.json List of applicants with GitHub usernames
|
||||
|
||||
Outputs (written to data/):
|
||||
- proficiency_evaluations.json Proficiency scores and tech stacks per applicant
|
||||
|
||||
Env vars:
|
||||
- None (uses public GitHub API; may be rate-limited without authentication)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
# GitHub API (no auth needed for public repos, but rate limited)
|
||||
GITHUB_API = "https://api.github.com"
|
||||
@@ -234,7 +253,7 @@ def evaluate_applicant(applicant: dict, index: int, total: int) -> dict:
|
||||
|
||||
def main():
|
||||
# Load applicants
|
||||
with open("applicants_to_evaluate.json") as f:
|
||||
with open(DATA_DIR / "applicants_to_evaluate.json") as f:
|
||||
applicants = json.load(f)
|
||||
|
||||
print(f"Evaluating {len(applicants)} applicants...\n")
|
||||
@@ -249,7 +268,7 @@ def main():
|
||||
print(f" Progress: {i + 1}/{len(applicants)} complete")
|
||||
|
||||
# Save results
|
||||
with open("proficiency_evaluations.json", "w") as f:
|
||||
with open(DATA_DIR / "proficiency_evaluations.json", "w") as f:
|
||||
json.dump(evaluations, f, indent=2)
|
||||
|
||||
# Summary
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch pinned messages from Ivory Orchid channel to find the roster."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
CHANNEL_ID = "1464316770889240730"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Fetch and print pinned messages from the Ivory Orchid channel."""
|
||||
headers = {
|
||||
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(
|
||||
f"https://discord.com/api/v10/channels/{CHANNEL_ID}/pins",
|
||||
headers=headers,
|
||||
) as response,
|
||||
):
|
||||
pins = await response.json()
|
||||
print(json.dumps(pins, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/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,23 +1,46 @@
|
||||
"""Generate markdown participant and leader profile files for the cohort.
|
||||
|
||||
Reads all evaluation data files and produces two markdown files summarising
|
||||
each member's tech stack, availability, proficiency, and leadership assessment.
|
||||
|
||||
Data files (place in data/):
|
||||
- discord_verification.json Discord ID verification results (from verify_discord.py)
|
||||
- proficiency_evaluations.json Proficiency scores (from evaluate_technical_proficiency.py)
|
||||
- availability_analysis.json Availability UTC blocks (from analyse_availability.py)
|
||||
- leadership_candidates.json List of applicants who expressed interest in leading
|
||||
- leadership_evaluations.json Leadership assessment scores
|
||||
|
||||
Outputs (written to data/):
|
||||
- participants.md Markdown profile for each participant
|
||||
- leaders.md Markdown profile for each leader candidate
|
||||
|
||||
Env vars:
|
||||
- None
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
BLOCK_EMOJIS = {"mornings": "🌅", "afternoons": "☀️", "evenings": "🌆", "nights": "🌙"}
|
||||
|
||||
|
||||
def load_all_data():
|
||||
"""Load all evaluation data files"""
|
||||
with open("discord_verification.json") as f:
|
||||
with open(DATA_DIR / "discord_verification.json") as f:
|
||||
verification = json.load(f)
|
||||
|
||||
with open("proficiency_evaluations.json") as f:
|
||||
with open(DATA_DIR / "proficiency_evaluations.json") as f:
|
||||
proficiency = json.load(f)
|
||||
|
||||
with open("availability_analysis.json") as f:
|
||||
with open(DATA_DIR / "availability_analysis.json") as f:
|
||||
availability = json.load(f)
|
||||
|
||||
with open("leadership_candidates.json") as f:
|
||||
with open(DATA_DIR / "leadership_candidates.json") as f:
|
||||
candidates = json.load(f)
|
||||
|
||||
with open("leadership_evaluations.json") as f:
|
||||
with open(DATA_DIR / "leadership_evaluations.json") as f:
|
||||
leadership = json.load(f)
|
||||
|
||||
return verification, proficiency, availability, candidates, leadership
|
||||
@@ -230,14 +253,14 @@ def main():
|
||||
participants_md = generate_participants_md(
|
||||
non_leader_ids, verified_usernames, prof_by_id, avail_by_id
|
||||
)
|
||||
with open("participants.md", "w") as f:
|
||||
with open(DATA_DIR / "participants.md", "w") as f:
|
||||
f.write(participants_md)
|
||||
print(f"Generated participants.md with {len(non_leader_ids)} participants")
|
||||
|
||||
leaders_md = generate_leaders_md(
|
||||
leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id
|
||||
)
|
||||
with open("leaders.md", "w") as f:
|
||||
with open(DATA_DIR / "leaders.md", "w") as f:
|
||||
f.write(leaders_md)
|
||||
print(f"Generated leaders.md with {len(leader_ids)} leaders")
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
"""Generate hourly timeslot JSON for use with Crabfit scheduling tool.
|
||||
|
||||
Produces a list of ISO-format datetime strings covering every hour across the
|
||||
scheduling window. Update the start_date and end_date constants before running.
|
||||
|
||||
Outputs (written to data/):
|
||||
- crabfit_timeslots.json List of hourly timeslot strings
|
||||
|
||||
Env vars:
|
||||
- None
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
# Generate hourly time slots from Feb 1 to March 3, 2026
|
||||
# 24 hours a day, America/Los_Angeles timezone
|
||||
@@ -18,7 +33,7 @@ print(f"First: {times[0]}")
|
||||
print(f"Last: {times[-1]}")
|
||||
|
||||
# Save to file for use
|
||||
with open("/home/naomi/docs/cohort/crabfit_timeslots.json", "w") as f:
|
||||
with open(DATA_DIR / "crabfit_timeslots.json", "w") as f:
|
||||
json.dump(times, f)
|
||||
|
||||
print("Saved to crabfit_timeslots.json")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List all roles in the guild to find the correct team role IDs."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
BASE_URL = "https://discord.com/api/v10"
|
||||
GUILD_ID = "1354624415861833870"
|
||||
|
||||
|
||||
async def list_guild_roles() -> None:
|
||||
"""List all roles in the guild, highlighting team-related ones."""
|
||||
url = f"{BASE_URL}/guilds/{GUILD_ID}/roles"
|
||||
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(url, headers=headers) as resp,
|
||||
):
|
||||
if resp.status == 200:
|
||||
roles = await resp.json()
|
||||
print(f"Found {len(roles)} roles in the server:\n")
|
||||
|
||||
team_names = [
|
||||
"Jade Jasmine",
|
||||
"Crimson Dahlia",
|
||||
"Rose Camellia",
|
||||
"Amber Wisteria",
|
||||
"Ivory Orchid",
|
||||
"Teal Iris",
|
||||
"Peach Gardenia",
|
||||
"Violet Carnation",
|
||||
"Azure Lotus",
|
||||
"Coral Sunflower",
|
||||
"Indigo Tulip",
|
||||
"Scarlet Hydrangea",
|
||||
"Mint Narcissus",
|
||||
"Sage Marigold",
|
||||
"Cohort",
|
||||
]
|
||||
|
||||
for role in sorted(roles, key=lambda r: r["name"].lower()):
|
||||
name = role["name"]
|
||||
is_team = any(team in name for team in team_names)
|
||||
if is_team or "spring" in name.lower() or "2026" in name:
|
||||
print(f"✅ {role['id']}: {name}")
|
||||
|
||||
print("\n\nAll roles (sorted by name):")
|
||||
for role in sorted(roles, key=lambda r: r["name"].lower()):
|
||||
print(f"{role['id']}: {role['name']}")
|
||||
|
||||
else:
|
||||
error = await resp.text()
|
||||
print(f"Error: {resp.status} - {error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(list_guild_roles())
|
||||
@@ -0,0 +1,48 @@
|
||||
"""List all Discord roles in the freeCodeCamp server."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
GUILD_ID = "692816967895220344"
|
||||
|
||||
API_BASE = "https://discord.com/api/v10"
|
||||
|
||||
|
||||
async def get_guild_roles(session: aiohttp.ClientSession) -> list[dict]:
|
||||
"""Get all guild roles."""
|
||||
url = f"{API_BASE}/guilds/{GUILD_ID}/roles"
|
||||
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}"}
|
||||
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
print(f"Failed to get roles: {resp.status}")
|
||||
text = await resp.text()
|
||||
print(text)
|
||||
return []
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""List all roles from freeCodeCamp Discord, highlighting cohort-related ones."""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
print("Fetching all roles from freeCodeCamp Discord...\n")
|
||||
roles = await get_guild_roles(session)
|
||||
|
||||
print(f"Found {len(roles)} roles:\n")
|
||||
|
||||
cohort_roles = [
|
||||
r
|
||||
for r in roles
|
||||
if "2026" in r["name"] or "Cohort" in r["name"] or "Leader" in r["name"]
|
||||
]
|
||||
|
||||
print("=== Cohort/Leader Roles ===")
|
||||
for role in cohort_roles:
|
||||
print(f" {role['name']}: {role['id']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/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,10 +1,29 @@
|
||||
"""Send initial welcome and roster messages to all team Discord channels.
|
||||
|
||||
Creates a pinned roster message in each team channel and stores the resulting
|
||||
message ID, channel ID, and role ID in team_message_ids.json for use by
|
||||
other scripts (send_checkin.py, update_roster_messages.py, etc.).
|
||||
|
||||
Data files (place in data/):
|
||||
- team_assignments.json Team rosters with leaders and participants per team
|
||||
- applicants_to_evaluate.json Applicant data including Discord channel/role IDs
|
||||
|
||||
Outputs (written to data/):
|
||||
- team_message_ids.json Channel ID, message ID, and role ID per team
|
||||
|
||||
Env vars:
|
||||
- DISCORD_BOT_TOKEN Bot token for the Discord API
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Amari's bot token
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
GUILD_ID = "692816967895220344"
|
||||
|
||||
@@ -72,12 +91,12 @@ TEAMS = {
|
||||
}
|
||||
|
||||
# Load team assignments and convert to dict by team name
|
||||
with open("team_assignments.json") as f:
|
||||
with open(DATA_DIR / "team_assignments.json") as f:
|
||||
team_list = json.load(f)
|
||||
team_data = {team["name"]: team for team in team_list}
|
||||
|
||||
# Load applicants to get project_url by discord_id
|
||||
with open("applicants_to_evaluate.json") as f:
|
||||
with open(DATA_DIR / "applicants_to_evaluate.json") as f:
|
||||
applicants = json.load(f)
|
||||
applicant_lookup = {str(a["discord_id"]): a for a in applicants}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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,8 +1,26 @@
|
||||
"""Verify Discord user IDs from a markdown table of applicant data.
|
||||
|
||||
Reads a markdown table containing Discord IDs and checks each one against the
|
||||
Discord API to confirm the user exists. Handles rate limits automatically.
|
||||
|
||||
Data files (place in data/):
|
||||
- table.md Markdown table of applicants including a Discord ID column
|
||||
|
||||
Outputs (written to data/):
|
||||
- discord_verification.json Verification result (valid/invalid) per Discord ID
|
||||
|
||||
Env vars:
|
||||
- DISCORD_BOT_TOKEN Bot token for the Discord API
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
# Configuration
|
||||
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||
@@ -10,7 +28,7 @@ GUILD_ID = "692816967895220344"
|
||||
BASE_URL = "https://discord.com/api/v10"
|
||||
|
||||
# Read Discord IDs from table.md
|
||||
with open("table.md") as f:
|
||||
with open(DATA_DIR / "table.md") as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.strip().split("\n")
|
||||
@@ -104,7 +122,7 @@ print(f"Missing: {len(missing)}")
|
||||
print(f"Errors: {len(errors)}")
|
||||
|
||||
# Save results
|
||||
with open("discord_verification.json", "w") as f:
|
||||
with open(DATA_DIR / "discord_verification.json", "w") as f:
|
||||
json.dump({"verified": verified, "missing": missing, "errors": errors}, f, indent=2)
|
||||
|
||||
print("\nResults saved to discord_verification.json")
|
||||
|
||||
Reference in New Issue
Block a user