Read the README.md of every repo in the GitLab challenges group via the REST API and rework the HedgeDoc note for project tracking: challenges ready for infra testing first, then status by category and a category x difficulty grid. Free-text status/difficulty/category values are normalised; untouched templates and repos scheduled for deletion are left out of the counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.1 KiB
Markdown
134 lines
5.1 KiB
Markdown
# ctf_status.py
|
||
|
||
Reads the `README.md` of every repo in the GitLab challenges group (REST API, no
|
||
clone) and shows which categories have challenges in which state, at which
|
||
difficulty — in the terminal and as a HedgeDoc note.
|
||
|
||
## Setup
|
||
|
||
```sh
|
||
cp ctf_status.env.example ctf_status.env && chmod 600 ctf_status.env
|
||
$EDITOR ctf_status.env # GitLab group URL + token, HedgeDoc note
|
||
```
|
||
|
||
## Use
|
||
|
||
```sh
|
||
./ctf_status.py # terminal summary
|
||
./ctf_status.py -o overview.md # + HedgeDoc markdown to a file
|
||
./ctf_status.py --stdout | wl-copy # straight to clipboard, paste into HedgeDoc
|
||
./ctf_status.py --update # overwrite the note in HEDGEDOC_NOTE
|
||
./ctf_status.py --update <note-url> # ... or an explicit one
|
||
```
|
||
|
||
The token is a fine-grained personal access token on the challenges group with
|
||
**Project: Read** (to list the repos) and **Repository: Read** (to download the
|
||
READMEs). A plain `.env` with `GL_PAT=` / `GITLAB_URL=` is read as well.
|
||
|
||
## Keeping a HedgeDoc note up to date
|
||
|
||
`--update` overwrites an **existing** note in place, so the URL you share stays the
|
||
same. Every run rewrites the whole body, starting with
|
||
|
||
```
|
||
---
|
||
tags: Logistics, h4tum CTF 2026
|
||
---
|
||
```
|
||
|
||
and a `**Last updated:** <date> <time> <tz>` line under the heading, so a stale note
|
||
is obvious at a glance. Tags come from `HEDGEDOC_TAGS` (or `--tags`).
|
||
|
||
The note's permission has to be **freely** (Menu → Permission), because the run is
|
||
anonymous. HedgeDoc 1.x has no REST API for editing a note, so the script talks the
|
||
editor's own engine.io/socket.io + OT protocol over long-polling — it reads the
|
||
current text, sends one "delete everything, insert this" operation, waits for the
|
||
server's `ack`, and reads the note back to confirm. Anyone with the note open sees
|
||
the change live. Still `requests`-only, no new dependencies.
|
||
|
||
`--hedgedoc <instance>` (POST a *new* note) is unrelated and only works where
|
||
anonymous note creation is enabled — on `hedgedoc.cit.tum.de` it is not.
|
||
|
||
## Every 30 minutes, on a server
|
||
|
||
Copy the repo to the server, create `ctf_status.env` there (it is gitignored, so it
|
||
does not travel with the code), then:
|
||
|
||
```sh
|
||
./install-timer.sh
|
||
```
|
||
|
||
It writes systemd **user** units into `~/.config/systemd/user/`, substituting
|
||
whatever directory the repo actually sits in, enables the timer, turns on lingering
|
||
so it keeps firing when you are not logged in, and does one run to prove it works.
|
||
Re-run it any time; it just overwrites the units.
|
||
|
||
```sh
|
||
systemctl --user list-timers ctf-status.timer # when it next fires
|
||
journalctl --user -u ctf-status.service -n 50 # what it did
|
||
systemctl --user start ctf-status.service # run it now
|
||
systemctl --user disable --now ctf-status.timer # stop it
|
||
```
|
||
|
||
The timer fires at `:00` and `:30` with up to a minute of jitter, and
|
||
`Persistent=true` catches up a run missed while the machine was down. Plain cron
|
||
works too, if you would rather:
|
||
|
||
```
|
||
*/30 * * * * /path/to/planner/ctf_status.py --update --quiet
|
||
```
|
||
|
||
`--quiet` keeps a successful run silent, so you only hear about it when the note
|
||
changed or something broke. Overlapping runs are prevented by a lock file, and the
|
||
HedgeDoc write is retried (`--retries`, default 3) before the run gives up with a
|
||
one-line error and a non-zero exit.
|
||
|
||
## What the note shows
|
||
|
||
1. **✅ Ready for infra testing** — first thing in the note: every challenge at
|
||
Testing Ready, Tested or Ready, linked to its repo, so infra knows what it
|
||
can deploy and test against right now.
|
||
2. **Status by category** — how many challenges each category has per stage
|
||
(⚪ Planning → 🟠 PoC Draft → 🟡 Testing Ready → 🔵 Tested → 🟢 Ready).
|
||
3. **Category × difficulty** — one dot per challenge, coloured by its status, so
|
||
gaps and stragglers show up at a glance.
|
||
4. **Challenges** — one table per category, linked to the repo, with event,
|
||
author, tester and the repo's last activity.
|
||
5. **Needs attention** — see below.
|
||
|
||
## Reading the README
|
||
|
||
Only the template's header block is parsed:
|
||
|
||
```
|
||
### Challenge Name
|
||
- Category: crypto
|
||
- Difficulty: medium
|
||
- Event: TUM student CTF
|
||
- Author: ...
|
||
- Tester: ...
|
||
- Status: Testing Ready
|
||
```
|
||
|
||
People fill it in loosely, so values are normalised:
|
||
|
||
- **Status** is matched against the five stages ("Tested with …" → Tested,
|
||
"PoC" → PoC Draft). If it names several ("Planning to PoC Draft", a half-deleted
|
||
option list) the *least* advanced one counts and it is flagged as unclear.
|
||
- **Difficulty** ranges written as `medium-hard`, `medium/hard` or
|
||
`easy to medium` become their own column; otherwise the first level named wins.
|
||
- **Category**: the first one named counts ("rev, misc" → rev); the full value
|
||
is shown next to the challenge. Categories outside the usual list (e.g. `ai`)
|
||
get their own row.
|
||
|
||
## What it flags
|
||
|
||
- challenge at Testing Ready or beyond with no tester (`[Tester Name]`, `??`,
|
||
`None`, `not found yet` all count as none)
|
||
- status / difficulty / category missing or unclear
|
||
- no challenge name in the README
|
||
- repos whose README is missing or still the untouched template — these are left
|
||
out of the counts
|
||
|
||
Repos GitLab has scheduled for deletion, and archived repos, are skipped.
|