From ef6bff29e49e7441ef4677d9d834f4d25873288e Mon Sep 17 00:00:00 2001 From: Justin Garrison Date: Sat, 12 Sep 2026 20:54:48 -0700 Subject: [PATCH] Auto-close PRs and check alphabetical order (#889) - 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 `

` or `

` 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. --- .github/src/readme_pr_alphabetical.py | 224 +++++++++++++++++++++ .github/src/readme_pr_repo_first_commit.py | 188 +++++++++++++++-- .github/workflows/awesome_bot.yaml | 23 --- .github/workflows/link_check.yml | 40 ++++ 4 files changed, 440 insertions(+), 35 deletions(-) create mode 100644 .github/src/readme_pr_alphabetical.py delete mode 100644 .github/workflows/awesome_bot.yaml create mode 100644 .github/workflows/link_check.yml diff --git a/.github/src/readme_pr_alphabetical.py b/.github/src/readme_pr_alphabetical.py new file mode 100644 index 0000000..db90f2f --- /dev/null +++ b/.github/src/readme_pr_alphabetical.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Alphabetical-order check for README.md additions. + +For each section (h2 within
) 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"]*>\s*\s*

([^<]+)

\s*
", re.IGNORECASE) +H3_RE = re.compile(r"

([^<]+)

", re.IGNORECASE) +DETAILS_CLOSE_RE = re.compile(r"
", 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" diff --git a/.github/src/readme_pr_repo_first_commit.py b/.github/src/readme_pr_repo_first_commit.py index 9eeeb58..2b3bf27 100644 --- a/.github/src/readme_pr_repo_first_commit.py +++ b/.github/src/readme_pr_repo_first_commit.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import base64 import calendar +import importlib.util import json import os import re @@ -8,9 +10,25 @@ from datetime import date, datetime, timezone 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 = "" +CLOSE_COMMENT_MARKER = "" URL_PATTERN = re.compile(r"https?://[^\s)\]>\"`]+") +STATUS_PASS = "pass" +STATUS_TOO_YOUNG = "too_young" +STATUS_UNKNOWN = "unknown" + +ALPHA_MARKER = "" + def fail(message: str) -> None: 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() -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 - months before today (UTC); ⛔ otherwise or if date missing/unparseable. + Returns (display_date, emoji, status). + 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) if not parsed: - return ("—", "⛔") + return ("—", "⛔", STATUS_UNKNOWN) today_utc = datetime.now(timezone.utc).date() cutoff = date_months_before(today_utc, 6) - emoji = "✅" if parsed <= cutoff else "⛔" - return (parsed.isoformat(), emoji) + if parsed <= cutoff: + return (parsed.isoformat(), "✅", STATUS_PASS) + return (parsed.isoformat(), "⛔", STATUS_TOO_YOUNG) 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)} |" -def build_comment_table(readme_urls: list[str], client: GithubClient) -> str: - """Rows sorted by Start (date) ascending; rows without a date sort last.""" +def build_comment_table(readme_urls: list[str], client: GithubClient) -> tuple[str, list[tuple[str, str, str]]]: + """ + 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] = {} rows: list[tuple[date | None, str, str, str]] = [] + statuses: list[tuple[str, str, str]] = [] for raw_url in readme_urls: 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) 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) 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)) @@ -203,7 +233,46 @@ def build_comment_table(readme_urls: list[str], client: GithubClient) -> str: ) body_lines = [MARKER, "", header] 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: @@ -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: token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") 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") readme_urls = [] + readme_patch = "" if isinstance(changed_files, list): 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: comment_body = ( f"{MARKER}\n" "README.md was changed, but no new or modified URL was found in added lines." ) 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__": diff --git a/.github/workflows/awesome_bot.yaml b/.github/workflows/awesome_bot.yaml deleted file mode 100644 index b15303f..0000000 --- a/.github/workflows/awesome_bot.yaml +++ /dev/null @@ -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 diff --git a/.github/workflows/link_check.yml b/.github/workflows/link_check.yml new file mode 100644 index 0000000..3e0680e --- /dev/null +++ b/.github/workflows/link_check.yml @@ -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