Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions .github/scripts/pr_approval_reminder.py
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


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. <https://github.com/{repo}/pulls|Open PRs>"
),
}
],
}
)
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())
76 changes: 31 additions & 45 deletions .github/workflows/pr-reminders.yml
Original file line number Diff line number Diff line change
@@ -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}
Loading
Loading