diff --git a/.github/workflows/sync_roadmap_status.yml b/.github/workflows/sync_roadmap_status.yml index 5194817..0acc23f 100644 --- a/.github/workflows/sync_roadmap_status.yml +++ b/.github/workflows/sync_roadmap_status.yml @@ -9,12 +9,19 @@ on: # (already in the event payload) and write to ROADMAP.md on main. pull_request_target: types: [opened, ready_for_review, closed] + # Run reconcile.py on demand ("Run workflow" button) and once a day + # as a safety net against any missed event (e.g. an issue closed + # before its ROADMAP.md line had "— #N" linked to it yet). + workflow_dispatch: {} + schedule: + - cron: "0 4 * * *" permissions: contents: write jobs: sync: + if: github.event_name == 'issues' || github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -39,6 +46,39 @@ jobs: commit_message: "chore: sync roadmap status [skip ci]" file_pattern: ROADMAP.md + - name: Sync Discord + if: steps.update.outputs.changed == 'true' + run: python scripts/roadmap/sync_discord.py + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_MESSAGE_IDS: ${{ vars.DISCORD_MESSAGE_IDS }} + + reconcile: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Reconcile ROADMAP.md with issue states + id: update + run: python scripts/roadmap/reconcile_roadmap.py + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit ROADMAP.md + if: steps.update.outputs.changed == 'true' + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: reconcile roadmap status [skip ci]" + file_pattern: ROADMAP.md + - name: Sync Discord if: steps.update.outputs.changed == 'true' run: python scripts/roadmap/sync_discord.py diff --git a/ROADMAP.md b/ROADMAP.md index 5ade97d..a948ae0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ This may be subject to change. 1. Introduction * [x] How course works? * [x] Motivation and Mindset - * [ ] Asking for Help (WIP) — #32 + * [ ] Asking for Help — #32 * [ ] Join the Community — #33 * [x] Computer Science basics, Programming and Python 2. Prerequisites diff --git a/scripts/roadmap/reconcile_roadmap.py b/scripts/roadmap/reconcile_roadmap.py new file mode 100644 index 0000000..930994e --- /dev/null +++ b/scripts/roadmap/reconcile_roadmap.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Scans every issue already linked in ROADMAP.md ("— #N") and sets its +checkbox to match that issue's CURRENT state on GitHub — closed, +assigned, or neither. + +Use this: + - once, right after linking a batch of issues, to catch anything that + changed *before* the link existed — sync_roadmap.py only reacts to + events on lines that already have "— #N" on them, so a PR merged + before you added the link is invisible to it + - on a schedule, as a safety net in case a future event is ever missed + (a failed workflow run, a webhook delivery hiccup, etc.) + +Unlike sync_roadmap.py, this doesn't look at PR state directly — an +issue's own state (closed / has an assignee) is used as the signal. +That covers the common paths (assigned, merged-and-closed) even for +history that predates the automation. +""" + +import json +import os +import sys +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(__file__)) +from sync_roadmap import LINE_RE, ROADMAP_PATH, apply_updates # noqa: E402 + + +def find_linked_issues() -> set[int]: + with open(ROADMAP_PATH, encoding="utf-8") as f: + lines = f.readlines() + + issues = set() + for line in lines: + m = LINE_RE.match(line.rstrip("\n")) + if m: + issues.add(int(m.group("issue"))) + return issues + + +def fetch_issue(repo: str, token: str, number: int) -> dict: + url = f"https://api.github.com/repos/{repo}/issues/{number}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + ) + with urllib.request.urlopen(req) as resp: + return json.load(resp) + + +def target_state(issue: dict) -> str: + if issue.get("state") == "closed": + return "x" + if issue.get("assignees"): + return "-" + return " " + + +def main() -> None: + repo = os.environ["GITHUB_REPOSITORY"] + token = os.environ["GITHUB_TOKEN"] + + issue_numbers = find_linked_issues() + if not issue_numbers: + print("No linked issues found in ROADMAP.md.") + return + + updates: dict[int, str] = {} + for number in sorted(issue_numbers): + try: + issue = fetch_issue(repo, token, number) + except urllib.error.HTTPError as e: + print( + f"#{number}: could not fetch issue ({e.code}) — skipping", + file=sys.stderr, + ) + continue + updates[number] = target_state(issue) + + changed = apply_updates(updates) + if not changed: + print("Roadmap already matched issue states.") + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"changed={'true' if changed else 'false'}\n") + + +if __name__ == "__main__": + main()