diff --git a/.github/scripts/pr_approval_reminder.py b/.github/scripts/pr_approval_reminder.py new file mode 100644 index 0000000000..b596e42a33 --- /dev/null +++ b/.github/scripts/pr_approval_reminder.py @@ -0,0 +1,231 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Post a Slack summary of open PRs whose CI waits for maintainer approval. + +GitHub holds ``pull_request`` runs from new contributors' forks with the +conclusion ``action_required`` until a maintainer approves them. These runs +have an empty ``pull_requests`` list, so they are matched to open PRs by the +head commit. The message groups the PRs by their AI triage label. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +APPROVAL_CONCLUSION = "action_required" +AI_LABEL_PREFIX = "ai:" +MAX_LISTED_PRS = 40 +MAX_SECTION_CHARS = 2_900 +MAX_TITLE_CHARS = 90 +# (heading, label that selects the group); None collects the rest. +GROUPS: tuple[tuple[str, str | None], ...] = ( + (":white_check_mark: AI triage found no CI concern", "ai:ci-safe"), + (":warning: AI triage asks for care", "ai:ci-needs-care"), + (":grey_question: Not triaged", None), +) + + +@dataclass(slots=True) +class WaitingPr: + number: int + title: str + url: str + author: str + waiting_since: datetime + workflows: list[str] + ai_labels: list[str] + + +def gh_api(path: str) -> list[dict[str, Any]]: + command = ["gh", "api", "--paginate", "--slurp", path] + output = subprocess.run(command, check=True, capture_output=True, text=True).stdout + pages = json.loads(output) + items: list[dict[str, Any]] = [] + for page in pages: + # List endpoints return arrays; the runs endpoint wraps them. + items.extend(page["workflow_runs"] if isinstance(page, dict) else page) + return items + + +def _parse_time(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def waiting_runs_by_sha(runs: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + waiting: dict[str, list[dict[str, Any]]] = {} + for run in runs: + if ( + run.get("event") == "pull_request" + and run.get("conclusion") == APPROVAL_CONCLUSION + ): + waiting.setdefault(run["head_sha"], []).append(run) + return waiting + + +def find_waiting_prs( + pulls: list[dict[str, Any]], waiting: dict[str, list[dict[str, Any]]] +) -> list[WaitingPr]: + prs: list[WaitingPr] = [] + for pull in pulls: + runs = waiting.get(pull["head"]["sha"]) + if not runs or pull.get("draft"): + continue + prs.append( + WaitingPr( + number=int(pull["number"]), + title=str(pull["title"]), + url=str(pull["html_url"]), + author=str(pull["user"]["login"]), + waiting_since=min(_parse_time(run["created_at"]) for run in runs), + workflows=sorted({str(run["name"]) for run in runs}), + ai_labels=sorted( + label["name"] + for label in pull.get("labels", []) + if label["name"].startswith(AI_LABEL_PREFIX) + ), + ) + ) + return sorted(prs, key=lambda pr: pr.waiting_since) + + +def slack_escape(text: str) -> str: + """Escape text for Slack mrkdwn, so it cannot form links or mentions.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _age(since: datetime, now: datetime) -> str: + hours = max(int((now - since).total_seconds() // 3600), 0) + return f"{hours // 24} d" if hours >= 48 else f"{hours} h" + + +def format_pr(pr: WaitingPr, now: datetime) -> str: + title = pr.title if len(pr.title) <= MAX_TITLE_CHARS else pr.title[:89] + "…" + title = slack_escape(title).replace("|", "¦") + labels = " ".join( + f"`{slack_escape(label)}`" + for label in pr.ai_labels + if label not in {"ai:ci-safe", "ai:ci-needs-care"} + ) + details = [ + f"by {slack_escape(pr.author)}", + f"waiting {_age(pr.waiting_since, now)}", + ] + if labels: + details.append(labels) + return f"• <{pr.url}|#{pr.number} {title}>\n {' · '.join(details)}" + + +def _sections(lines: list[str]) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [] + chunk = "" + for line in lines: + if chunk and len(chunk) + len(line) + 1 > MAX_SECTION_CHARS: + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": chunk}} + ) + chunk = "" + chunk = f"{chunk}\n{line}" if chunk else line + if chunk: + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}}) + return blocks + + +def build_message(prs: list[WaitingPr], repo: str, now: datetime) -> dict[str, Any]: + count = len(prs) + summary = f"{count} PR(s) in {repo} wait for a maintainer to approve CI" + blocks: list[dict[str, Any]] = [ + {"type": "header", "text": {"type": "plain_text", "text": f"🚦 {summary}"}}, + ] + listed = prs[:MAX_LISTED_PRS] + for heading, label in GROUPS: + if label is None: + known = {group_label for _, group_label in GROUPS if group_label} + members = [pr for pr in listed if not known & set(pr.ai_labels)] + else: + members = [pr for pr in listed if label in pr.ai_labels] + if not members: + continue + lines = [f"*{heading}* ({len(members)})"] + lines.extend(format_pr(pr, now) for pr in members) + blocks.extend(_sections(lines)) + if count > len(listed): + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"… and {count - len(listed)} more. Oldest first.", + } + ], + } + ) + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ( + "The `ai:*` labels are advisory. Read the PR before you" + f" approve CI. " + ), + } + ], + } + ) + return {"text": summary, "blocks": blocks} + + +def post_to_slack(webhook_url: str, payload: dict[str, Any]) -> None: + request = urllib.request.Request( + webhook_url, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 200: + raise RuntimeError(f"Slack returned HTTP {response.status}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True) + parser.add_argument( + "--dry-run", action="store_true", help="Print the message, do not post." + ) + args = parser.parse_args(argv) + + runs = gh_api( + f"repos/{args.repo}/actions/runs?status={APPROVAL_CONCLUSION}&per_page=100" + ) + pulls = gh_api(f"repos/{args.repo}/pulls?state=open&per_page=100") + prs = find_waiting_prs(pulls, waiting_runs_by_sha(runs)) + print(f"{len(prs)} open PR(s) wait for CI approval.") + if not prs: + return 0 + + payload = build_message(prs, args.repo, datetime.now(timezone.utc)) + webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "") + if args.dry_run or not webhook_url: + if not webhook_url and not args.dry_run: + print("::warning::SLACK_WEBHOOK_URL is not set. Printing the message only.") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + post_to_slack(webhook_url, payload) + print("Posted the reminder to Slack.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/pr-reminders.yml b/.github/workflows/pr-reminders.yml index cc1d5f7f6e..2aa741d9a0 100644 --- a/.github/workflows/pr-reminders.yml +++ b/.github/workflows/pr-reminders.yml @@ -1,55 +1,41 @@ name: PR Workflow Approval Reminder +# Every morning, list the open PRs whose CI waits for a maintainer to approve +# it, grouped by their AI triage label, and post the list to Slack. on: - # schedule: - # - cron: "0 */6 * * *" # every 6 hours + schedule: + - cron: "0 6 * * *" + timezone: "Europe/Zurich" workflow_dispatch: + inputs: + dry_run: + description: Print the message in the log instead of posting it. + type: boolean + default: false + +permissions: {} jobs: - check-prs: + remind: + # Forks also run schedules. Only the upstream repository posts to Slack. + if: github.repository == 'docling-project/docling' runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: read + pull-requests: read steps: - - name: Check PRs blocked by workflow approval - id: filter - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { data: pulls } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open' - }); - - let result = ''; - - for (const pr of pulls) { - const { data: runs } = await github.rest.actions.listWorkflowRunsForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - event: 'pull_request', - head_sha: pr.head.sha - }); - - const waitingRuns = runs.workflow_runs.filter(r => r.status === 'waiting'); - if (waitingRuns.length > 0) { - const runNames = waitingRuns.map(r => r.name).join(', '); - result += `• **PR #${pr.number}**: [${pr.title}](${pr.html_url}) \n ⏸️ Workflows: ${runNames}\n\n`; - } - } - - let message; - if (result === '') { - message = '✅ No PRs are blocked by workflow approval right now.'; - } else { - message = `🚦 **PRs waiting for maintainer approval to run workflows:**\n\n${result}`; - } - - core.setOutput('message', message); - - - name: Send message to Discord via webhook + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Post the reminder to Slack + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SLACK_WEBHOOK_URL: ${{ secrets.PR_REMINDER_SLACK_WEBHOOK }} + DRY_RUN: ${{ inputs.dry_run && '--dry-run' || '' }} run: | - payload=$(jq -n --arg content "${{ steps.filter.outputs.message }}" '{content: $content}') - curl -X POST -H "Content-Type: application/json" \ - -d "$payload" \ - ${{ secrets.PR_REMINDER_DISCORD_WEBHOOK }} + python3 .github/scripts/pr_approval_reminder.py --repo "${REPO}" ${DRY_RUN} diff --git a/tests/test_pr_approval_reminder.py b/tests/test_pr_approval_reminder.py new file mode 100644 index 0000000000..f1d5dc9ec7 --- /dev/null +++ b/tests/test_pr_approval_reminder.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import importlib.util +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SPEC = importlib.util.spec_from_file_location( + "pr_approval_reminder", + Path(__file__).resolve().parents[1] / ".github/scripts/pr_approval_reminder.py", +) +assert SPEC is not None and SPEC.loader is not None +reminder = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = reminder +SPEC.loader.exec_module(reminder) + +NOW = datetime(2026, 10, 8, 6, 0, tzinfo=timezone.utc) + + +def run(sha: str, name: str, created: str, **overrides: Any) -> dict[str, Any]: + data = { + "head_sha": sha, + "name": name, + "event": "pull_request", + "conclusion": "action_required", + "created_at": created, + } + data.update(overrides) + return data + + +def pull(number: int, sha: str, labels: tuple[str, ...] = (), **overrides: Any): + data = { + "number": number, + "title": f"fix: change {number}", + "html_url": f"https://github.com/o/r/pull/{number}", + "user": {"login": f"user{number}"}, + "head": {"sha": sha}, + "draft": False, + "labels": [{"name": label} for label in labels], + } + data.update(overrides) + return data + + +def test_only_current_head_commits_of_ready_prs_count() -> None: + runs = [ + run("aaa", "Run CI", "2026-10-06T06:00:00Z"), + run("aaa", "Run Docs CI", "2026-10-05T06:00:00Z"), + run("old", "Run CI", "2026-10-01T06:00:00Z"), + run("bbb", "Run CI", "2026-10-07T06:00:00Z", conclusion="success"), + run("ccc", "Run CI", "2026-10-07T06:00:00Z"), + ] + pulls = [ + pull(1, "aaa", ("ai:ci-safe", "docx")), + pull(2, "new"), # its waiting run belongs to an older commit + pull(3, "bbb"), + pull(4, "ccc", draft=True), + ] + prs = reminder.find_waiting_prs(pulls, reminder.waiting_runs_by_sha(runs)) + assert [pr.number for pr in prs] == [1] + assert prs[0].workflows == ["Run CI", "Run Docs CI"] + assert prs[0].waiting_since == datetime(2026, 10, 5, 6, tzinfo=timezone.utc) + assert prs[0].ai_labels == ["ai:ci-safe"] + + +def test_message_groups_by_triage_label_and_escapes_titles() -> None: + safe = reminder.WaitingPr( + 1, "fix: a & b|c", "u1", "alice", NOW, ["Run CI"], ["ai:ci-safe"] + ) + care = reminder.WaitingPr( + 2, + "feat: x", + "u2", + "bob", + datetime(2026, 10, 5, 6, tzinfo=timezone.utc), + ["Run CI"], + ["ai:ci-needs-care", "ai:possible-duplicate"], + ) + plain = reminder.WaitingPr(3, "docs: y", "u3", "carol", NOW, ["Run CI"], []) + payload = reminder.build_message([care, safe, plain], "o/r", NOW) + sections = [b["text"]["text"] for b in payload["blocks"] if b["type"] == "section"] + assert len(sections) == 3 + assert sections[0].startswith( + "*:white_check_mark: AI triage found no CI concern* (1)" + ) + assert "<!channel> & b¦c" in sections[0] + assert sections[1].startswith("*:warning: AI triage asks for care* (1)") + assert "waiting 3 d · `ai:possible-duplicate`" in sections[1] + assert sections[2].startswith("*:grey_question: Not triaged* (1)") + assert payload["text"] == "3 PR(s) in o/r wait for a maintainer to approve CI" + + +def test_long_lists_are_split_and_capped() -> None: + prs = [ + reminder.WaitingPr(n, "t" * 90, f"https://x/{n}", "u", NOW, ["CI"], []) + for n in range(60) + ] + payload = reminder.build_message(prs, "o/r", NOW) + sections = [b for b in payload["blocks"] if b["type"] == "section"] + assert len(sections) > 1 + assert all(len(b["text"]["text"]) <= reminder.MAX_SECTION_CHARS for b in sections) + assert "… and 20 more" in payload["blocks"][-2]["elements"][0]["text"]