Auto-close PRs and check alphabetical order

- readme_pr_repo_first_commit.py:
  - Close a PR when every added GitHub repo is confirmed younger than
    the 6-month minimum. Unknown/unparseable dates are treated as
    comment-only and do not trigger close.
  - Append an alphabetical-order section to the marker comment when
    the PR adds a list item out of order in any `<details><h2>` or
    `<h3>` sub-section of README.md. Only flags violations that
    involve a line added by the PR.
  - Reconstructs the post-PR README from base + diff so the check
    works on PRs from forks without needing fork-head access.
- readme_pr_alphabetical.py (new): shared helper for the alphabetical
  check.
- workflows/awesome_bot.yaml: removed. awesome_bot was running on
  every push and PR, frequently failing on transient network issues
  and 404s.
- workflows/link_check.yml (new): weekly lychee check on README.md
  (Mon 06:17 UTC + manual dispatch). Tolerant of transient errors,
  posts results back to the Actions run summary.
This commit is contained in:
Justin Garrison
2026-09-12 20:52:00 -07:00
parent ea974fd995
commit 39d8dd8495
4 changed files with 440 additions and 35 deletions
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Alphabetical-order check for README.md additions.
For each section (h2 within <details>) and sub-section (h3 inside an
h2 section), verify that list items are sorted alphabetically (case-
insensitive). Only flag violations that involve a line added by the PR,
not pre-existing misordering.
"""
import re
ITEM_RE = re.compile(r"^- \[([^\]]+)\]")
H2_OPEN_RE = re.compile(r"<details\b[^>]*>\s*<summary>\s*<h2>([^<]+)</h2>\s*</summary>", re.IGNORECASE)
H3_RE = re.compile(r"<h3>([^<]+)</h3>", re.IGNORECASE)
DETAILS_CLOSE_RE = re.compile(r"</details>", re.IGNORECASE)
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
def parse_sections(content: str) -> list[dict]:
"""Parse README into sections.
Returns a list of:
{"name": str, "items": [(line_no, name)], "subsections": [{name, items}]}
Sections with sub-sections (e.g. Libraries) yield one entry per
sub-section in `subsections` and the top-level `items` is empty.
"""
sections: list[dict] = []
current: dict | None = None
current_sub: dict | None = None
for i, raw in enumerate(content.splitlines(), start=1):
line = raw.rstrip("\r")
if H2_OPEN_RE.search(line):
current = {"name": H2_OPEN_RE.search(line).group(1).strip(), "items": [], "subsections": []}
current_sub = None
sections.append(current)
continue
if H3_RE.search(line):
if current is not None:
current_sub = {"name": H3_RE.search(line).group(1).strip(), "items": []}
current["subsections"].append(current_sub)
continue
if DETAILS_CLOSE_RE.search(line):
current = None
current_sub = None
continue
m = ITEM_RE.match(line.strip())
if not m:
continue
name = m.group(1).strip()
entry = (i, name)
if current_sub is not None:
current_sub["items"].append(entry)
elif current is not None:
current["items"].append(entry)
return sections
def iter_checkable_groups(sections: list[dict]) -> list[tuple[str, list[tuple[int, str]]]]:
"""Return (display_name, items) pairs we should check for ordering."""
out: list[tuple[str, list[tuple[int, str]]]] = []
for s in sections:
if s["subsections"]:
for sub in s["subsections"]:
out.append((f"{s['name']} > {sub['name']}", sub["items"]))
else:
out.append((s["name"], s["items"]))
return out
def apply_patch(base_lines: list[str], patch_text: str) -> tuple[list[str], set[int]]:
"""Reconstruct the new file from base content and a unified diff.
Returns (new_lines_with_trailing_newlines, set_of_added_line_numbers_1indexed).
"""
new_lines: list[str] = []
added: set[int] = set()
hunks: list[dict] = []
current: dict | None = None
for line in patch_text.splitlines(keepends=False):
m = HUNK_RE.match(line)
if m:
if current is not None:
hunks.append(current)
current = {
"old_start": int(m.group(1)),
"new_start": int(m.group(2)),
"lines": [],
}
elif current is not None:
current["lines"].append(line)
if current is not None:
hunks.append(current)
old_pos = 1
new_pos = 1
for hunk in hunks:
while old_pos < hunk["old_start"]:
new_lines.append(base_lines[old_pos - 1])
old_pos += 1
new_pos += 1
for hunk_line in hunk["lines"]:
if hunk_line.startswith("---") or hunk_line.startswith("+++"):
continue
if hunk_line.startswith("\\"):
# "\ No newline at end of file" marker — skip
continue
if hunk_line.startswith("-"):
old_pos += 1
elif hunk_line.startswith("+"):
content = hunk_line[1:]
if not content.endswith("\n"):
content += "\n"
new_lines.append(content)
added.add(new_pos)
new_pos += 1
elif hunk_line.startswith(" "):
new_lines.append(base_lines[old_pos - 1])
old_pos += 1
new_pos += 1
while old_pos <= len(base_lines):
new_lines.append(base_lines[old_pos - 1])
old_pos += 1
new_pos += 1
return new_lines, added
def collect_added_lines_for_file(changed_file: dict) -> set[int]:
"""Parse a single changed-file's patch and return added line numbers (1-indexed)."""
patch = (changed_file or {}).get("patch") or ""
if not patch:
return set()
added: set[int] = set()
current_new: int | None = None
for line in patch.splitlines():
m = HUNK_RE.match(line)
if m:
current_new = int(m.group(2))
continue
if current_new is None:
continue
if line.startswith("+++"):
continue
if line.startswith("+"):
added.add(current_new)
current_new += 1
elif line.startswith("-"):
continue
elif line.startswith("\\"):
continue
else:
current_new += 1
return added
def find_ordering_violations(
items: list[tuple[int, str]],
added_lines: set[int],
) -> tuple[bool, list[str]]:
"""Check if `items` is alphabetically sorted (case-insensitive).
Returns (involves_added_line, list_of_descriptions).
Only violations where at least one of the two items is on a line
added by the PR are reported (and `involves_added_line` is True iff
any such violation exists). Pre-existing misordering is ignored.
"""
names = [n.casefold() for _, n in items]
descriptions: list[str] = []
involves_added = False
for i in range(len(items) - 1):
if names[i] > names[i + 1]:
line_a, name_a = items[i]
line_b, name_b = items[i + 1]
pair_involves_added = (line_a in added_lines) or (line_b in added_lines)
if pair_involves_added:
involves_added = True
descriptions.append(
f"`{name_a}` (line {line_a}) should come after `{name_b}` (line {line_b})"
)
return involves_added, descriptions
def check_alphabetical(base_content: str, readme_patch: str) -> str:
"""Return a markdown section (empty string if everything is sorted).
`base_content` is the README on the base branch.
`readme_patch` is the unified diff for README.md from the PR.
"""
base_lines = base_content.splitlines(keepends=True)
new_lines, added_lines = apply_patch(base_lines, readme_patch)
new_content = "".join(new_lines)
sections = parse_sections(new_content)
flagged: list[tuple[str, list[str]]] = []
for group_name, items in iter_checkable_groups(sections):
if len(items) < 2:
continue
involves_added, descriptions = find_ordering_violations(items, added_lines)
if involves_added and descriptions:
flagged.append((group_name, descriptions))
if not flagged:
return ""
lines = [
"",
"### Alphabetical order",
"",
"Items below are not in alphabetical order within their section. "
"Please sort the affected entries so the section stays alphabetical.",
"",
]
for group_name, descriptions in flagged:
lines.append(f"- **{group_name}**")
for d in descriptions:
lines.append(f" - {d}")
return "\n".join(lines) + "\n"
+176 -12
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import base64
import calendar import calendar
import importlib.util
import json import json
import os import os
import re import re
@@ -8,9 +10,25 @@ from datetime import date, datetime, timezone
from urllib import error, parse, request from urllib import error, parse, request
ALPHA_MODULE_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "readme_pr_alphabetical.py"
)
_alpha_spec = importlib.util.spec_from_file_location("readme_pr_alphabetical", ALPHA_MODULE_PATH)
_alpha_module = importlib.util.module_from_spec(_alpha_spec)
_alpha_spec.loader.exec_module(_alpha_module)
check_alphabetical = _alpha_module.check_alphabetical
MARKER = "<!-- readme-pr-repo-first-commit -->" MARKER = "<!-- readme-pr-repo-first-commit -->"
CLOSE_COMMENT_MARKER = "<!-- readme-pr-repo-first-commit:close -->"
URL_PATTERN = re.compile(r"https?://[^\s)\]>\"`]+") URL_PATTERN = re.compile(r"https?://[^\s)\]>\"`]+")
STATUS_PASS = "pass"
STATUS_TOO_YOUNG = "too_young"
STATUS_UNKNOWN = "unknown"
ALPHA_MARKER = "<!-- readme-pr-repo-first-commit:alphabetical -->"
def fail(message: str) -> None: def fail(message: str) -> None:
print(message, file=sys.stderr) print(message, file=sys.stderr)
@@ -135,18 +153,23 @@ def parse_iso_date_utc(iso_str: str) -> date | None:
return dt.astimezone(timezone.utc).date() return dt.astimezone(timezone.utc).date()
def go_no_go_for_first_commit(iso_date_str: str) -> tuple[str, str]: def go_no_go_for_first_commit(iso_date_str: str) -> tuple[str, str, str]:
""" """
Returns (display_date, emoji). ✅ if first commit is at least 6 calendar Returns (display_date, emoji, status).
months before today (UTC); ⛔ otherwise or if date missing/unparseable. status is one of:
- STATUS_PASS: first commit is at least 6 calendar months before today (UTC)
- STATUS_TOO_YOUNG: date is parseable but the project is younger than 6 months
- STATUS_UNKNOWN: date is missing or could not be parsed (treated as
comment-only; does NOT trigger auto-close)
""" """
parsed = parse_iso_date_utc(iso_date_str) parsed = parse_iso_date_utc(iso_date_str)
if not parsed: if not parsed:
return ("—", "⛔") return ("—", "⛔", STATUS_UNKNOWN)
today_utc = datetime.now(timezone.utc).date() today_utc = datetime.now(timezone.utc).date()
cutoff = date_months_before(today_utc, 6) cutoff = date_months_before(today_utc, 6)
emoji = "✅" if parsed <= cutoff else "⛔" if parsed <= cutoff:
return (parsed.isoformat(), emoji) return (parsed.isoformat(), "✅", STATUS_PASS)
return (parsed.isoformat(), "⛔", STATUS_TOO_YOUNG)
def get_first_commit_date(client: GithubClient, repo_url: str) -> str: def get_first_commit_date(client: GithubClient, repo_url: str) -> str:
@@ -176,10 +199,16 @@ def markdown_table_row(c1: str, c2: str, c3: str) -> str:
return f"| {esc(c1)} | {esc(c2)} | {esc(c3)} |" return f"| {esc(c1)} | {esc(c2)} | {esc(c3)} |"
def build_comment_table(readme_urls: list[str], client: GithubClient) -> str: def build_comment_table(readme_urls: list[str], client: GithubClient) -> tuple[str, list[tuple[str, str, str]]]:
"""Rows sorted by Start (date) ascending; rows without a date sort last.""" """
Build the marker comment body and return a parallel list of
(repo_url, status, iso_date) for every repo that was evaluated as a
real GitHub repo. Non-GitHub URLs are excluded from the status list
(they are treated as UNKNOWN and do not affect close decisions).
"""
first_commit_cache: dict[str, str] = {} first_commit_cache: dict[str, str] = {}
rows: list[tuple[date | None, str, str, str]] = [] rows: list[tuple[date | None, str, str, str]] = []
statuses: list[tuple[str, str, str]] = []
for raw_url in readme_urls: for raw_url in readme_urls:
repo_url = normalize_github_repo_url(raw_url) repo_url = normalize_github_repo_url(raw_url)
@@ -191,9 +220,10 @@ def build_comment_table(readme_urls: list[str], client: GithubClient) -> str:
first_commit_cache[repo_url] = get_first_commit_date(client, repo_url) first_commit_cache[repo_url] = get_first_commit_date(client, repo_url)
iso = first_commit_cache[repo_url] iso = first_commit_cache[repo_url]
display_date, emoji = go_no_go_for_first_commit(iso) display_date, emoji, status = go_no_go_for_first_commit(iso)
sort_key = parse_iso_date_utc(iso) sort_key = parse_iso_date_utc(iso)
rows.append((sort_key, display_date, emoji, repo_url)) rows.append((sort_key, display_date, emoji, repo_url))
statuses.append((repo_url, status, iso))
rows.sort(key=lambda r: (r[0] is None, r[0] or date.min)) rows.sort(key=lambda r: (r[0] is None, r[0] or date.min))
@@ -203,7 +233,46 @@ def build_comment_table(readme_urls: list[str], client: GithubClient) -> str:
) )
body_lines = [MARKER, "", header] body_lines = [MARKER, "", header]
body_lines.extend(markdown_table_row(c1, c2, c3) for _, c1, c2, c3 in rows) body_lines.extend(markdown_table_row(c1, c2, c3) for _, c1, c2, c3 in rows)
return "\n".join(body_lines) return "\n".join(body_lines), statuses
def build_close_comment(too_young: list[tuple[str, str]]) -> str:
"""Compose the comment posted when a PR is auto-closed."""
lines = [
CLOSE_COMMENT_MARKER,
"",
"Closing this PR because every repository added by this change is "
"younger than the 6-month minimum required by this list.",
"",
"| Repository | First commit |",
"| ---------- | ------------ |",
]
for repo_url, iso_date in too_young:
date_display = iso_date[:10] if iso_date else "—"
lines.append(f"| {repo_url} | {date_display} |")
lines += [
"",
"What to do next: please reopen this PR once each of the above "
"repositories has been active for at least 6 months (by first "
"commit). The status table above will refresh automatically when "
"you reopen or push changes.",
]
return "\n".join(lines)
def should_close_pr(statuses: list[tuple[str, str, str]]) -> tuple[bool, list[tuple[str, str]]]:
"""
Per repo policy:
- Close only if ALL added GitHub repos fail the 6-month requirement.
- UNKNOWN (missing/unparseable date) does NOT count as failing and
therefore blocks auto-close to avoid false positives.
Returns (should_close, list_of_(repo_url, iso_date)_for_too_young_repos).
"""
if not statuses:
return (False, [])
if any(status != STATUS_TOO_YOUNG for _, status, _ in statuses):
return (False, [])
return (True, [(repo_url, iso) for repo_url, _, iso in statuses])
def upsert_comment(client: GithubClient, pr_number: str, body: str) -> None: def upsert_comment(client: GithubClient, pr_number: str, body: str) -> None:
@@ -231,6 +300,74 @@ def upsert_comment(client: GithubClient, pr_number: str, body: str) -> None:
) )
def upsert_close_comment(client: GithubClient, pr_number: str, body: str) -> None:
"""Upsert a close-explanation comment under its own marker."""
comments, _ = client.api(f"repos/{client.repository}/issues/{pr_number}/comments?per_page=100")
existing_id = ""
if isinstance(comments, list):
for comment in comments:
comment_body = (comment or {}).get("body", "")
user_login = ((comment or {}).get("user") or {}).get("login", "")
if CLOSE_COMMENT_MARKER in comment_body and user_login == "github-actions[bot]":
existing_id = str(comment.get("id", ""))
if existing_id:
client.api(
f"repos/{client.repository}/issues/comments/{existing_id}",
method="PATCH",
payload={"body": body},
)
return
client.api(
f"repos/{client.repository}/issues/{pr_number}/comments",
method="POST",
payload={"body": body},
)
def get_pr_state(client: GithubClient, pr_number: str) -> str:
pr, _ = client.api(f"repos/{client.repository}/pulls/{pr_number}")
return (pr or {}).get("state", "")
def close_pull_request(client: GithubClient, pr_number: str) -> None:
client.api(
f"repos/{client.repository}/pulls/{pr_number}",
method="PATCH",
payload={"state": "closed"},
)
def fetch_base_readme(client: GithubClient, pr_number: str) -> str | None:
"""Return the README.md content from the PR's base ref, or None on failure.
Used by the alphabetical-order check to reconstruct the post-PR
README without requiring fork-head access.
"""
pr, _ = client.api(f"repos/{client.repository}/pulls/{pr_number}")
if not isinstance(pr, dict):
return None
base = pr.get("base", {}) or {}
ref = base.get("sha") or ""
if not ref:
return None
path = f"repos/{client.repository}/contents/README.md?ref={ref}"
try:
data, _ = client.api(path)
except SystemExit:
return None
if not isinstance(data, dict):
return None
content_b64 = data.get("content", "")
if not content_b64:
return None
try:
return base64.b64decode(content_b64).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
def main() -> None: def main() -> None:
token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
pr_number = os.getenv("PR_NUMBER") pr_number = os.getenv("PR_NUMBER")
@@ -245,18 +382,45 @@ def main() -> None:
changed_files, _ = client.api(f"repos/{repository}/pulls/{pr_number}/files?per_page=100") changed_files, _ = client.api(f"repos/{repository}/pulls/{pr_number}/files?per_page=100")
readme_urls = [] readme_urls = []
readme_patch = ""
if isinstance(changed_files, list): if isinstance(changed_files, list):
readme_urls = find_readme_urls(changed_files) readme_urls = find_readme_urls(changed_files)
for f in changed_files:
if (f or {}).get("filename") == "README.md":
readme_patch = (f or {}).get("patch") or ""
break
statuses: list[tuple[str, str, str]] = []
if not readme_urls: if not readme_urls:
comment_body = ( comment_body = (
f"{MARKER}\n" f"{MARKER}\n"
"README.md was changed, but no new or modified URL was found in added lines." "README.md was changed, but no new or modified URL was found in added lines."
) )
else: else:
comment_body = build_comment_table(readme_urls, client) comment_body, statuses = build_comment_table(readme_urls, client)
upsert_comment(client, pr_number=pr_number, body=comment_body) alphabetical_section = ""
if readme_patch:
base_content = fetch_base_readme(client, pr_number)
if base_content is not None:
alphabetical_section = check_alphabetical(base_content, readme_patch)
if alphabetical_section:
alphabetical_section = (
f"\n{ALPHA_MARKER}\n{alphabetical_section}"
)
upsert_comment(client, pr_number=pr_number, body=comment_body + alphabetical_section)
should_close, too_young = should_close_pr(statuses)
if not should_close:
return
state = get_pr_state(client, pr_number)
if state == "closed":
return
upsert_close_comment(client, pr_number, build_close_comment(too_young))
close_pull_request(client, pr_number)
if __name__ == "__main__": if __name__ == "__main__":
-23
View File
@@ -1,23 +0,0 @@
name: Ruby
on:
push:
branches: [ '*' ]
pull_request:
branches: [ '*' ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Ruby 2.6
uses: ruby/setup-ruby@v1
with:
ruby-version: 2.6
- name: Checks
run: |
gem install awesome_bot
awesome_bot --allow-redirect --request-delay 1 README.md
+40
View File
@@ -0,0 +1,40 @@
name: Link Checker
on:
schedule:
- cron: '17 6 * * 1'
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: link-check
cancel-in-progress: false
jobs:
link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lychee link checker
uses: lycheeverse/lychee-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: >-
--verbose
--no-progress
--accept 100,101,200,202,203,204,205,206,300,301,302,303,304,307,308,403,429
--retry 3
--retry-wait-time 5
--timeout 20
--max-redirects 5
--exclude-loopback
--exclude-mail
--exclude 'github\.com/.*/(issues|pulls|actions)'
README.md
output: github
fail: true