diff --git a/.github/workflows/update-meeting-archive.yaml b/.github/workflows/update-meeting-archive.yaml new file mode 100644 index 0000000..c1df791 --- /dev/null +++ b/.github/workflows/update-meeting-archive.yaml @@ -0,0 +1,71 @@ +name: Update meeting archive + +on: + workflow_dispatch: + # Meetings are fortnightly; a weekly run keeps the archive current whether or not + # the recording is published on the day of the meeting. + schedule: + - cron: '0 6 * * 5' + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-meeting-archive + cancel-in-progress: false + +jobs: + update-meeting-archive: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + ref: 'main' + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Look for new recordings + id: update + run: | + { + echo 'summary<> "$GITHUB_OUTPUT" + + - name: Commit and push + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add meetings/README.md + if git diff --cached --quiet; then + echo "nothing to commit" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git branch -D update-meeting-archive || true + git checkout -b update-meeting-archive + git commit --signoff -m "docs(meetings): add newly published meeting recordings" + git push --force --set-upstream origin update-meeting-archive + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Open a pull request unless one is already open + if: steps.commit.outputs.changed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUMMARY: ${{ steps.update.outputs.summary }} + run: | + if [[ "$(gh pr view update-meeting-archive --json state --jq .state 2>/dev/null)" == "OPEN" ]]; then + echo "a pull request is already open, the new commit is on its branch" + exit 0 + fi + gh pr create \ + --title "docs(meetings): add newly published meeting recordings" \ + --body "$(printf '%s\n\n%s\n' "$SUMMARY" "The Topics column is left empty on purpose — fill it in from the meeting notes before merging. Dates are derived from the publication date of the recording, snapped to the meeting's Thursday.")" \ + --head update-meeting-archive \ + --base main diff --git a/.gitignore b/.gitignore index d4d9525..6952c6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea .vscode .DS_Store +__pycache__/ diff --git a/hack/update-meeting-archive.py b/hack/update-meeting-archive.py new file mode 100755 index 0000000..7f65b93 --- /dev/null +++ b/hack/update-meeting-archive.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Add newly published community meeting recordings to the top of meetings/README.md. + +Reads the public Atom feed of the community meetings YouTube playlist, which needs +no API key and carries the fifteen most recent entries — plenty for a job that runs +weekly against a fortnightly meeting. + +Scope is deliberately narrow: only meetings newer than the newest row already in the +table. History is never touched, so a wrong date or a missing link in an old row +stays a job for a human, not something this script tries to guess at. + +Topics are left empty for a human to fill in while reviewing the pull request. The +script has no way to know what was discussed, and a plausible guess would be worse +than a blank. +""" + +from __future__ import annotations + +import datetime as dt +import re +import sys +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + +PLAYLIST_ID = "PLEIgpkcPkMHaXqndo8iMMLS64p4sBkHPg" +FEED_URL = f"https://www.youtube.com/feeds/videos.xml?playlist_id={PLAYLIST_ID}" +ARCHIVE = Path(__file__).resolve().parent.parent / "meetings" / "README.md" + +NS = { + "atom": "http://www.w3.org/2005/Atom", + "yt": "http://www.youtube.com/xml/schemas/2015", +} + +EM_DASH = "—" +ROW = re.compile(r"^\| (?P\d{4}-\d{2}-\d{2}) \| (?P.*?) \| (?P.*?) \|$") +VIDEO_ID = re.compile(r"youtube\.com/watch\?v=([\w-]+)") +COUNTS = re.compile(r"^Meetings listed: \d+\. Recordings available: \d+\.") + +# Meetings are held on Thursdays and the recording is published the same day or +# shortly after, so the publication date snaps back to the meeting date. This beats +# parsing the video title, where the day and month have been swapped often enough to +# make an unambiguous title unreliable. +THURSDAY = 3 +MAX_SNAP_DAYS = 4 + + +def fetch_entries() -> list[tuple[str, str, dt.date]]: + with urllib.request.urlopen(FEED_URL, timeout=30) as response: + feed = ET.fromstring(response.read()) + entries = [] + for entry in feed.findall("atom:entry", NS): + video_id = entry.findtext("yt:videoId", namespaces=NS) + title = (entry.findtext("atom:title", namespaces=NS) or "").strip() + published = entry.findtext("atom:published", namespaces=NS) + if not video_id or not published: + continue + entries.append((video_id, title, dt.datetime.fromisoformat(published).date())) + return sorted(entries, key=lambda entry: entry[2]) + + +def meeting_date(published: dt.date) -> tuple[dt.date, bool]: + """Return the meeting date for a publication date, and whether it looks off.""" + offset = (published.weekday() - THURSDAY) % 7 + candidates = [published - dt.timedelta(days=offset), published - dt.timedelta(days=offset - 7)] + nearest = min(candidates, key=lambda day: abs((published - day).days)) + return nearest, abs((published - nearest).days) > MAX_SNAP_DAYS + + +def main() -> int: + lines = ARCHIVE.read_text(encoding="utf-8").splitlines() + rows = [index for index, line in enumerate(lines) if ROW.match(line)] + if not rows: + print("no meeting rows found in the archive", file=sys.stderr) + return 1 + + top = rows[0] + newest_listed = ROW.match(lines[top])["date"] + known_videos = { + video for index in rows for video in VIDEO_ID.findall(ROW.match(lines[index])["recording"]) + } + + added: list[str] = [] + flagged: list[str] = [] + for video_id, title, published in fetch_entries(): + if video_id in known_videos: + continue + date, uncertain = meeting_date(published) + if str(date) <= newest_listed: + continue + link = f"[Watch](https://www.youtube.com/watch?v={video_id})" + lines.insert(top, f"| {date} | {link} | |") + added.append(f"{date} — {title} (published {published})") + if uncertain: + flagged.append(f"{date} — publication was not near a Thursday: {title}") + + if not added: + print("no new recordings") + return 0 + + rows = [index for index, line in enumerate(lines) if ROW.match(line)] + recordings = sum(1 for index in rows if ROW.match(lines[index])["recording"].strip() != EM_DASH) + for index, line in enumerate(lines): + if COUNTS.match(line): + lines[index] = COUNTS.sub( + f"Meetings listed: {len(rows)}. Recordings available: {recordings}.", line + ) + + ARCHIVE.write_text("\n".join(lines) + "\n", encoding="utf-8") + + print("New meetings added — fill in the Topics column before merging:") + print("\n".join(f"- {note}" for note in added)) + if flagged: + print("Worth a look, the date was derived rather than read:") + print("\n".join(f"- {note}" for note in flagged)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/meetings/README.md b/meetings/README.md index 101b977..c68d9da 100644 --- a/meetings/README.md +++ b/meetings/README.md @@ -87,3 +87,5 @@ For how the meeting is run and how to join the next one, see [community_meeting. Meetings listed: 72. Recordings available: 62. Where the recording column shows an em dash, the meeting took place but no recording was published — the notes in the shared document remain the record for that date. +Rows for meetings with a recording are added automatically once it appears in the playlist. A meeting that was cancelled, or held without a recording, produces nothing to detect, so its row is added by hand — usually by whoever ran or cancelled the meeting. +