Files
ephemere/python/cohort/send_activity_report.py
T
hikari ec58c9c843
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
feat: reorganise bash scripts and add comprehensive documentation (#6)
## 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>
2026-02-23 20:18:41 -08:00

179 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Send formatted activity report tables to each team's Discord channel.
Parses catch_up_report.md and posts a monospace table of each member's Discord
and GitHub activity stats to their respective team channel.
Data files (place in data/):
- catch_up_report.md Activity report generated by catch_up_report.py
Env vars:
- DISCORD_BOT_TOKEN Bot token for the Discord API
"""
import asyncio
import os
import aiohttp
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
API_BASE = "https://discord.com/api/v10"
CHANNEL_IDS = {
"Crimson Dahlia": "1464316744909852682",
"Rose Camellia": "1464316751268286611",
"Amber Wisteria": "1464316761410113641",
"Ivory Orchid": "1464316770889240730",
"Teal Iris": "1464316776459407448",
"Peach Gardenia": "1464316785040953543",
"Violet Carnation": "1464316805261824032",
"Azure Lotus": "1464316814455472139",
"Coral Sunflower": "1464316819711066263",
"Indigo Tulip": "1464316826384072925",
"Scarlet Hydrangea": "1464316839306985506",
"Mint Narcissus": "1464316844251807952",
"Sage Marigold": "1464316850669093040",
}
REPORT_PATH = os.path.join(
os.path.dirname(__file__), "..", "..", "data", "catch_up_report.md"
)
FIELDS = [
("Discord Username", "Name", 18),
("Discord Messages", "Msgs", 5),
("PRs Opened", "PRs", 4),
("Issues Opened", "Issues", 6),
("Issue Comments", "Issue♟", 7),
("PR Comments", "PR♟", 5),
("PR Reviews", "Reviews", 7),
("Commits", "Commits", 7),
]
def parse_report(path: str) -> dict[str, list[dict]]:
"""Parse the markdown table from catch_up_report.md into team → rows."""
teams: dict[str, list[dict]] = {}
with open(path, encoding="utf-8") as f:
lines = f.readlines()
header_line = None
for i, line in enumerate(lines):
if line.startswith("| Discord ID |"):
header_line = i
break
if header_line is None:
raise ValueError("Could not find table header in report")
headers = [h.strip() for h in lines[header_line].strip().strip("|").split("|")]
for line in lines[header_line + 2 :]:
line = line.strip()
if not line.startswith("|"):
break
row_values = [v.strip() for v in line.strip().strip("|").split("|")]
row = dict(zip(headers, row_values))
team = row["Team"]
teams.setdefault(team, []).append(row)
return teams
def format_table(members: list[dict]) -> str:
"""Format a team's member list as a monospace table for Discord."""
members = sorted(members, key=lambda r: int(r["Discord Messages"]), reverse=True)
col_widths = [width for _, _, width in FIELDS]
col_headers = [header for _, header, _ in FIELDS]
name_col_index = 0
max_name = max(len(m["Discord Username"]) for m in members)
col_widths[name_col_index] = max(col_widths[name_col_index], max_name)
def pad(val: str, width: int, right_align: bool = False) -> str:
return val.rjust(width) if right_align else val.ljust(width)
header_row = " ".join(
pad(col_headers[i], col_widths[i], right_align=(i > 0))
for i in range(len(FIELDS))
)
separator = " ".join("-" * w for w in col_widths)
rows = []
for m in members:
source_keys = [key for key, _, _ in FIELDS]
values = [m[key] for key in source_keys]
row = " ".join(
pad(values[i], col_widths[i], right_align=(i > 0))
for i in range(len(FIELDS))
)
rows.append(row)
return "\n".join([header_row, separator] + rows)
async def send_message(
session: aiohttp.ClientSession, channel_id: str, content: str
) -> None:
"""Send a message to a Discord channel."""
headers = {
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
"Content-Type": "application/json",
}
url = f"{API_BASE}/channels/{channel_id}/messages"
while True:
async with session.post(
url, json={"content": content}, headers=headers
) as resp:
if resp.status == 429:
data = await resp.json()
retry_after = data.get("retry_after", 5)
print(f" Rate limited — sleeping {retry_after}s...")
await asyncio.sleep(retry_after)
continue
if resp.status not in (200, 201):
text = await resp.text()
print(f" ERROR {resp.status}: {text}")
return
async def main() -> None:
"""Send activity tables to all team channels."""
teams = parse_report(REPORT_PATH)
team_names = list(CHANNEL_IDS.keys())
print(f"Sending activity tables to {len(team_names)} channels...\n")
async with aiohttp.ClientSession() as session:
for i, team_name in enumerate(team_names, 1):
channel_id = CHANNEL_IDS[team_name]
members = teams.get(team_name, [])
if not members:
print(f" [{i}/{len(team_names)}] {team_name} — no data, skipping")
continue
table = format_table(members)
message = (
f"**{team_name} — Activity Report (Feb 1523)**\n```\n{table}\n```"
)
if len(message) > 2000:
print(
f" [{i}/{len(team_names)}] {team_name} — WARNING: "
f"message is {len(message)} chars (over 2000!)"
)
print(
f" [{i}/{len(team_names)}] Sending to {team_name}... ",
end="",
flush=True,
)
await send_message(session, channel_id, message)
print("sent!")
await asyncio.sleep(1)
print("\nAll done! 🌸")
if __name__ == "__main__":
asyncio.run(main())