diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..109df8e --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,125 @@ +name: PR Check + +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check-pr: + name: Check PR + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch base ref + run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install -q -r lib/requirements.txt + + - name: Detect changed files + id: changes + run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Check for direct README edits + id: readme + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Schema validation + id: schema + if: steps.changes.outputs.yaml_changed == 'true' + run: make validate + + - name: YAML diff analysis + id: diff + if: steps.changes.outputs.yaml_changed == 'true' + run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Link validation + if: steps.changes.outputs.yaml_changed == 'true' && steps.diff.outputs.has_service_changes == 'true' + run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + + - name: PR template check + id: template + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: python lib/checks/check-template.py + + - name: Non-YAML changes warning + if: steps.changes.outputs.non_yaml_changed == 'true' + run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Save PR metadata + if: always() + run: | + mkdir -p /tmp/pr-meta + echo "${{ github.event.pull_request.number }}" > /tmp/pr-meta/number.txt + echo "${{ github.run_id }}" > /tmp/pr-meta/run-id.txt + + - name: Build failure comment + if: failure() + run: | + cat > /tmp/pr-meta/comment.md << 'HEADER' + + > [!CAUTION] + > ## PR check failed + HEADER + + if [ "${{ steps.readme.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Direct README edits + The auto-generated section of the README must not be edited directly. + Please make your changes in `awesome-privacy.yml` instead — the README is regenerated from it. + EOF + fi + + if [ "${{ steps.schema.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Schema validation failed + `awesome-privacy.yml` does not conform to the expected schema. + Please check your YAML against the structure defined in `lib/schema.json`. + EOF + fi + + if [ "${{ steps.diff.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Single-entry rule + Each PR should add, modify, or remove only **one** service at a time. + If you need to change multiple services, please split them into separate PRs. + EOF + fi + + if [ "${{ steps.template.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### PR template incomplete + Your PR description is missing required sections. + Please fill out the **Type**, **Changes**, and **Checklist** sections of the PR template. + EOF + fi + + # Footer with link to logs + printf '\n---\n*See the [workflow logs](%s/%s/actions/runs/%s) for full details.*\n' \ + "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" \ + >> /tmp/pr-meta/comment.md + + - name: Upload PR metadata + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-meta + path: /tmp/pr-meta/ diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 0000000..4034dba --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -0,0 +1,105 @@ +name: PR Comment + +on: + workflow_run: + workflows: ["PR Check"] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + comment: + name: Post PR comment + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Download PR metadata + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: pr-meta + path: pr-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post or update comment + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const marker = ''; + const conclusion = context.payload.workflow_run.conclusion; + + // Determine the PR number + let prNumber; + const numberFile = 'pr-meta/number.txt'; + if (fs.existsSync(numberFile)) { + prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim()); + } else { + // workflow_run.pull_requests is empty for fork PRs, so + // fall back to searching by head SHA if needed + const prs = context.payload.workflow_run.pull_requests; + if (prs && prs.length > 0) { + prNumber = prs[0].number; + } else { + const headSha = context.payload.workflow_run.head_sha; + const { data: prList } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 10, + }); + const match = prList.find(pr => pr.head.sha === headSha); + if (!match) { + console.log(`No open PR found for SHA ${headSha} — skipping comment.`); + return; + } + prNumber = match.number; + } + } + + // Find existing bot comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(c => c.body.includes(marker)); + + if (conclusion === 'failure') { + const commentFile = 'pr-meta/comment.md'; + if (!fs.existsSync(commentFile)) { + console.log('No comment.md found — skipping comment.'); + return; + } + const body = fs.readFileSync(commentFile, 'utf8').trim(); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + } else if (existing) { + // Checks passed — remove stale failure comment + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } diff --git a/lib/checks/__pycache__/check-links.cpython-310.pyc b/lib/checks/__pycache__/check-links.cpython-310.pyc new file mode 100644 index 0000000..4fc6b74 Binary files /dev/null and b/lib/checks/__pycache__/check-links.cpython-310.pyc differ diff --git a/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc b/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc new file mode 100644 index 0000000..6eb4ca8 Binary files /dev/null and b/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc differ diff --git a/lib/checks/__pycache__/check-template.cpython-310.pyc b/lib/checks/__pycache__/check-template.cpython-310.pyc new file mode 100644 index 0000000..d49b256 Binary files /dev/null and b/lib/checks/__pycache__/check-template.cpython-310.pyc differ diff --git a/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc new file mode 100644 index 0000000..51c91bf Binary files /dev/null and b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc differ diff --git a/lib/checks/__pycache__/detect-changes.cpython-310.pyc b/lib/checks/__pycache__/detect-changes.cpython-310.pyc new file mode 100644 index 0000000..89c8601 Binary files /dev/null and b/lib/checks/__pycache__/detect-changes.cpython-310.pyc differ diff --git a/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc new file mode 100644 index 0000000..5329521 Binary files /dev/null and b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc differ diff --git a/lib/checks/check-links.py b/lib/checks/check-links.py new file mode 100644 index 0000000..293fa24 --- /dev/null +++ b/lib/checks/check-links.py @@ -0,0 +1,211 @@ +""" +Validates URLs and metadata for added/modified services in a PR. +Reads the diff JSON produced by check-yaml-diff.py. +All checks are warnings only -- this script never fails (exit 0). +""" + +import argparse +import json +import os +import sys + +import requests +import yaml + +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") + +# Exit codes +EXIT_PASS = 0 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + +TIMEOUT = 10 +USER_AGENT = "awesome-privacy-ci/1.0" +DESC_MIN_LEN = 50 +DESC_MAX_LEN = 250 + + +def check_url(url, label): + """Check if a URL is reachable. Returns (ok, message).""" + try: + resp = requests.head( + url, + timeout=TIMEOUT, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + if resp.status_code >= 400: + # Retry with GET -- some servers reject HEAD + resp = requests.get( + url, + timeout=TIMEOUT, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + stream=True, + ) + resp.close() + if resp.status_code >= 400: + return False, f"{label}: HTTP {resp.status_code} for {url}" + return True, None + except requests.RequestException as e: + return False, f"{label}: Connection error for {url} ({type(e).__name__})" + + +def check_service(service_data, service_name, category, section): + """Run all checks on a single service. Returns list of warning strings.""" + warnings = [] + name_prefix = f"{category} > {section} > {service_name}" + + # Check url + url = service_data.get("url") + if url: + ok, msg = check_url(url, "url") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + else: + warnings.append(f"{name_prefix}: missing required field 'url'") + + # Check icon + icon = service_data.get("icon") + if icon: + ok, msg = check_url(icon, "icon") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + else: + warnings.append(f"{name_prefix}: missing 'icon' field (recommended by contributing guide)") + + # Check iosApp + ios_app = service_data.get("iosApp") + if ios_app: + ok, msg = check_url(ios_app, "iosApp") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + + # Check github + github = service_data.get("github") + if github: + github_url = f"https://github.com/{github}" + ok, msg = check_url(github_url, "github") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + + # Check description length + desc = service_data.get("description", "") + desc_stripped = desc.strip() + desc_len = len(desc_stripped) + if desc_len < DESC_MIN_LEN: + warnings.append(f"{name_prefix}: description too short ({desc_len} chars, minimum {DESC_MIN_LEN})") + elif desc_len > DESC_MAX_LEN: + warnings.append(f"{name_prefix}: description too long ({desc_len} chars, maximum {DESC_MAX_LEN})") + + return warnings + + +def find_service_in_head(category, section, service_name): + """Look up a service in the head YAML by category/section/service name.""" + try: + with open(DATA_PATH, "r") as f: + data = yaml.safe_load(f) + for cat in data.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + for svc in sec.get("services", []): + if svc.get("name") == service_name: + return svc + except Exception: + pass + return None + + +def write_step_summary(all_warnings, services_checked): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## Link Validation\n"] + + if not services_checked: + lines.append("No services to check.\n") + elif not all_warnings: + lines.append(f"All checks passed for {services_checked} service(s).\n") + else: + lines.append(f"Checked {services_checked} service(s), found {len(all_warnings)} warning(s):\n") + lines.append("| Warning |") + lines.append("|---------|") + for w in all_warnings: + escaped = w.replace("|", "\\|") + lines.append(f"| {escaped} |") + lines.append("") + lines.append("> **Note:** Link warnings are informational only and do not fail the check. " + "URLs may be temporarily down or block automated requests.\n") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Validate links for added/modified services") + parser.add_argument("--diff-json", required=True, help="Path to the diff JSON file") + args = parser.parse_args() + + # Load diff + try: + with open(args.diff_json, "r") as f: + diff = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(red(f"Failed to load diff JSON: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + all_warnings = [] + services_checked = 0 + + # Check added services + for svc in diff.get("services", {}).get("added", []): + services_checked += 1 + warnings = check_service( + svc.get("fields", {}), + svc["service"], + svc["category"], + svc["section"], + ) + all_warnings.extend(warnings) + + # Check modified services -- only if they have URL-related field changes + for svc in diff.get("services", {}).get("modified", []): + url_fields = {"url", "icon", "iosApp", "github", "description"} + changed = set(svc.get("changed_fields", [])) + if changed & url_fields: + services_checked += 1 + head_svc = find_service_in_head(svc["category"], svc["section"], svc["service"]) + if head_svc: + warnings = check_service( + head_svc, + svc["service"], + svc["category"], + svc["section"], + ) + all_warnings.extend(warnings) + + # Print results + if all_warnings: + print(yellow(f"Link validation: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + else: + print(green(f"Link validation passed. {services_checked} service(s) checked.")) + + write_step_summary(all_warnings, services_checked) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-readme-edits.py b/lib/checks/check-readme-edits.py new file mode 100644 index 0000000..aa733b5 --- /dev/null +++ b/lib/checks/check-readme-edits.py @@ -0,0 +1,123 @@ +""" +Fails if the PR directly edits the auto-generated section of the README. +The generated section is between and . +""" + +import argparse +import os +import re +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +README_PATH = ".github/README.md" +README_ABS = os.path.join(PROJECT_ROOT, README_PATH) + +# Exit codes +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) + + +def get_changed_files(base_ref): + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + return result.stdout.strip().splitlines() + + +def get_marker_lines(): + """Find the line numbers of the start/end markers in the README.""" + try: + with open(README_ABS, "r") as f: + lines = f.readlines() + except FileNotFoundError: + return None, None + + start_line = None + end_line = None + for i, line in enumerate(lines, start=1): + if "" in line: + start_line = i + if "" in line: + end_line = i + + return start_line, end_line + + +def get_changed_line_numbers(base_ref): + """Parse git diff hunk headers to find which lines were changed in the README.""" + result = subprocess.run( + ["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_lines = [] + for line in result.stdout.splitlines(): + # Match hunk headers like @@ -10,5 +12,7 @@ + match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line) + if match: + start = int(match.group(1)) + count = int(match.group(2)) if match.group(2) else 1 + for n in range(start, start + count): + changed_lines.append(n) + + return changed_lines + + +def write_step_summary(): + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = [ + "## Direct README Edit Detected\n", + "This PR directly modifies the auto-generated section of `.github/README.md` " + "(between `` and ``).\n", + "**Please edit `awesome-privacy.yml` instead.** The README is regenerated automatically from that file.\n", + ] + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Check for direct README edits to generated section") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + # Skip if README wasn't changed + changed_files = get_changed_files(args.base_ref) + if README_PATH not in changed_files: + print(green("README not modified, skipping.")) + sys.exit(EXIT_PASS) + + # Find marker lines + start_line, end_line = get_marker_lines() + if start_line is None or end_line is None: + print("Could not find generated-section markers in README, skipping check.") + sys.exit(EXIT_PASS) + + # Check if any changed lines fall within the generated section + changed_lines = get_changed_line_numbers(args.base_ref) + for line_num in changed_lines: + if start_line <= line_num <= end_line: + print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr) + print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr) + write_step_summary() + sys.exit(EXIT_FAIL) + + print(green("README changes are outside the generated section, OK.")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-template.py b/lib/checks/check-template.py new file mode 100644 index 0000000..b53671d --- /dev/null +++ b/lib/checks/check-template.py @@ -0,0 +1,206 @@ +""" +Checks PR body against the pull request template. +Reads the PR body from the PR_BODY environment variable (avoids shell injection). +Exits with code 1 for severe violations (empty body, missing required sections). +""" + +import os +import re +import sys + +# Exit codes +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_RUNTIME_ERROR = 2 + +# Warnings that should cause a hard failure +CRITICAL_WARNINGS = { + "PR body is empty or not provided", + "Type section is missing", + "Type section is empty", + "Changes section is missing", + "Changes section is empty", + "Checklist section is missing", + "Checklist section does not contain any checkbox items", +} + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + +# Valid PR types from the template +VALID_TYPES = {"Addition", "Amendment", "Removal", "Spelling or Grammar", "Website Update", "Misc"} + +# Raw template text that indicates an unfilled section +RAW_TYPE_LINE = "Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc" + + +def strip_html_comments(text): + """Remove comments from text.""" + return re.sub(r"", "", text, flags=re.DOTALL).strip() + + +def extract_section(body, header): + """Extract content between a ### header and the next --- or ### header.""" + pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" + match = re.search(pattern, body, re.DOTALL) + if match: + return match.group(1) + return None + + +def check_type_section(content): + """Check the Type section. Returns list of warning strings.""" + warnings = [] + + if content is None: + warnings.append("Type section is missing") + return warnings + + cleaned = strip_html_comments(content).strip() + + if not cleaned: + warnings.append("Type section is empty") + return warnings + + # Check for raw template text (unchanged) + if RAW_TYPE_LINE in cleaned: + warnings.append("Type section appears unchanged from the template -- please select one type") + return warnings + + # Check how many valid types are present + found_types = [t for t in VALID_TYPES if t in cleaned] + + if len(found_types) == 0: + warnings.append(f"Type section does not contain a recognized type. Expected one of: {', '.join(sorted(VALID_TYPES))}") + elif len(found_types) > 1: + warnings.append(f"Type section contains multiple types ({', '.join(found_types)}) -- please select only one") + + return warnings + + +def check_text_section(content, section_name): + """Check a text section (Changes, Supporting Material, Affiliation). Returns list of warnings.""" + warnings = [] + + if content is None: + warnings.append(f"{section_name} section is missing") + return warnings + + cleaned = strip_html_comments(content).strip() + + if not cleaned: + warnings.append(f"{section_name} section is empty") + + return warnings + + +def check_checklist(content): + """Check the Checklist section. Returns list of warnings.""" + warnings = [] + + if content is None: + warnings.append("Checklist section is missing") + return warnings + + checked = re.findall(r"- \[x\]", content, re.IGNORECASE) + unchecked = re.findall(r"- \[ \]", content) + + total = len(checked) + len(unchecked) + + if total == 0: + warnings.append("Checklist section does not contain any checkbox items") + return warnings + + if unchecked: + warnings.append(f"Checklist has {len(unchecked)} unchecked item(s) out of {total}") + + return warnings + + +def has_critical_warnings(warnings): + """Return True if any warning is a critical (hard-fail) violation.""" + return any(w in CRITICAL_WARNINGS for w in warnings) + + +def write_step_summary(all_warnings): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## PR Template Check\n"] + + if not all_warnings: + lines.append("All template checks passed.\n") + else: + critical = has_critical_warnings(all_warnings) + lines.append(f"Found {len(all_warnings)} warning(s):\n") + for w in all_warnings: + lines.append(f"- {w}") + lines.append("") + if critical: + lines.append("> **Error:** One or more required sections are missing or empty. " + "Please fill out the PR template before this check can pass.\n") + else: + lines.append("> **Note:** Template warnings are informational and do not fail the check. " + "Reviewers will verify compliance.\n") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + pr_body = os.environ.get("PR_BODY") + + if pr_body is None or pr_body.strip() == "": + all_warnings = [ + "PR body is empty or not provided", + "Type section is missing", + "Changes section is missing", + "Supporting Material section is missing", + "Affiliation section is missing", + "Checklist section is missing", + ] + print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + write_step_summary(all_warnings) + sys.exit(EXIT_FAIL) + + all_warnings = [] + + # Check each section + type_content = extract_section(pr_body, "Type") + all_warnings.extend(check_type_section(type_content)) + + changes_content = extract_section(pr_body, "Changes") + all_warnings.extend(check_text_section(changes_content, "Changes")) + + supporting_content = extract_section(pr_body, "Supporting Material") + all_warnings.extend(check_text_section(supporting_content, "Supporting Material")) + + affiliation_content = extract_section(pr_body, "Affiliation") + all_warnings.extend(check_text_section(affiliation_content, "Affiliation")) + + checklist_content = extract_section(pr_body, "Checklist") + all_warnings.extend(check_checklist(checklist_content)) + + # Print results + if all_warnings: + print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + else: + print(green("PR template check passed.")) + + write_step_summary(all_warnings) + if has_critical_warnings(all_warnings): + sys.exit(EXIT_FAIL) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py new file mode 100644 index 0000000..2931e68 --- /dev/null +++ b/lib/checks/check-yaml-diff.py @@ -0,0 +1,342 @@ +""" +Analyzes the diff between base and head versions of awesome-privacy.yml. +Enforces the single-entry rule: only one service addition/amendment/removal per PR. +Outputs a JSON diff to /tmp/pr-diff.json and writes a step summary. +""" + +import argparse +import json +import os +import subprocess +import sys + +import yaml + +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_OUTPUT_PATH = "/tmp/pr-diff.json" + +# Exit codes +EXIT_PASS = 0 +EXIT_RULE_VIOLATION = 1 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) +dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s) + + +def load_base_yaml(base_ref): + """Load the YAML from the base ref using git show.""" + try: + result = subprocess.run( + ["git", "show", f"{base_ref}:awesome-privacy.yml"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + return yaml.safe_load(result.stdout) + except subprocess.CalledProcessError: + # File doesn't exist in base (completely new file) + print(yellow("Warning: awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr) + return {"categories": []} + except yaml.YAMLError as e: + print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def load_head_yaml(): + """Load the YAML from the current working tree.""" + try: + with open(DATA_PATH, "r") as f: + return yaml.safe_load(f) + except FileNotFoundError: + print(red(f"File not found: {DATA_PATH}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + except yaml.YAMLError as e: + print(red(f"Failed to parse head YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def build_service_index(data): + """Build a dict keyed by (category, section, service_name) -> service dict.""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + for sec in cat.get("sections", []): + sec_name = sec.get("name", "") + for svc in sec.get("services", []): + svc_name = svc.get("name", "") + key = (cat_name, sec_name, svc_name) + index[key] = svc + return index + + +def build_section_index(data): + """Build a dict keyed by (category, section) -> section metadata (excluding services).""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + for sec in cat.get("sections", []): + sec_name = sec.get("name", "") + key = (cat_name, sec_name) + meta = {k: v for k, v in sec.items() if k != "services"} + index[key] = meta + return index + + +def build_category_index(data): + """Build a dict keyed by category_name -> category metadata (excluding sections).""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + meta = {k: v for k, v in cat.items() if k != "sections"} + index[cat_name] = meta + return index + + +def diff_services(base_data, head_data): + """Find added, removed, and modified services.""" + base_idx = build_service_index(base_data) + head_idx = build_service_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + added = [] + for key in sorted(head_keys - base_keys): + added.append({ + "category": key[0], + "section": key[1], + "service": key[2], + "fields": head_idx[key], + }) + + removed = [] + for key in sorted(base_keys - head_keys): + removed.append({ + "category": key[0], + "section": key[1], + "service": key[2], + }) + + modified = [] + for key in sorted(base_keys & head_keys): + base_svc = base_idx[key] + head_svc = head_idx[key] + if base_svc != head_svc: + changed_fields = [] + all_fields = set(base_svc.keys()) | set(head_svc.keys()) + for field in sorted(all_fields): + old_val = base_svc.get(field) + new_val = head_svc.get(field) + if old_val != new_val: + changed_fields.append(field) + modified.append({ + "category": key[0], + "section": key[1], + "service": key[2], + "changed_fields": changed_fields, + }) + + return added, removed, modified + + +def diff_sections(base_data, head_data): + """Find section-level metadata changes (intro, wordOfWarning, etc.).""" + base_idx = build_section_index(base_data) + head_idx = build_section_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + changes = [] + + # New sections + for key in sorted(head_keys - base_keys): + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "added_section", + }) + + # Removed sections + for key in sorted(base_keys - head_keys): + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "removed_section", + }) + + # Modified section metadata + for key in sorted(base_keys & head_keys): + base_meta = base_idx[key] + head_meta = head_idx[key] + if base_meta != head_meta: + changed_fields = [] + all_fields = set(base_meta.keys()) | set(head_meta.keys()) + for field in sorted(all_fields): + if base_meta.get(field) != head_meta.get(field): + changed_fields.append(field) + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "modified_section_metadata", + "changed_fields": changed_fields, + }) + + return changes + + +def diff_categories(base_data, head_data): + """Find structural category changes.""" + base_idx = build_category_index(base_data) + head_idx = build_category_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + changes = [] + + for name in sorted(head_keys - base_keys): + changes.append({"category": name, "change_type": "added_category"}) + + for name in sorted(base_keys - head_keys): + changes.append({"category": name, "change_type": "removed_category"}) + + return changes + + +def write_github_output(name, value): + """Write a value to $GITHUB_OUTPUT.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def write_step_summary(diff_result): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## YAML Diff Analysis\n"] + + added = diff_result["services"]["added"] + removed = diff_result["services"]["removed"] + modified = diff_result["services"]["modified"] + section_changes = diff_result["sections"] + category_changes = diff_result["categories"] + + if not added and not removed and not modified and not section_changes and not category_changes: + lines.append("No changes detected in `awesome-privacy.yml`.\n") + else: + lines.append("| Type | Category | Section | Service | Details |") + lines.append("|------|----------|---------|---------|---------|") + + for svc in added: + lines.append(f"| Added | {svc['category']} | {svc['section']} | {svc['service']} | New service |") + + for svc in removed: + lines.append(f"| Removed | {svc['category']} | {svc['section']} | {svc['service']} | Service removed |") + + for svc in modified: + fields = ", ".join(svc["changed_fields"]) + lines.append(f"| Modified | {svc['category']} | {svc['section']} | {svc['service']} | Changed: {fields} |") + + for change in section_changes: + detail = change["change_type"].replace("_", " ").title() + fields = ", ".join(change.get("changed_fields", [])) + if fields: + detail += f" ({fields})" + lines.append(f"| Section | {change['category']} | {change['section']} | - | {detail} |") + + for change in category_changes: + detail = change["change_type"].replace("_", " ").title() + lines.append(f"| Category | {change['category']} | - | - | {detail} |") + + lines.append("") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Analyze YAML diff for PR checks") + parser.add_argument("--base-ref", required=True, help="Base git ref (SHA or branch) to diff against") + args = parser.parse_args() + + # Load both versions + base_data = load_base_yaml(args.base_ref) + head_data = load_head_yaml() + + # Compute diffs + added, removed, modified = diff_services(base_data, head_data) + section_changes = diff_sections(base_data, head_data) + category_changes = diff_categories(base_data, head_data) + + # Build result + diff_result = { + "services": { + "added": added, + "removed": removed, + "modified": modified, + }, + "sections": section_changes, + "categories": category_changes, + } + + # Write diff JSON + with open(DIFF_OUTPUT_PATH, "w") as f: + json.dump(diff_result, f, indent=2) + print(f"Diff written to {DIFF_OUTPUT_PATH}") + + # Determine if there are service-level changes + has_service_changes = bool(added or removed or modified) + write_github_output("has_service_changes", str(has_service_changes).lower()) + + # Write step summary + write_step_summary(diff_result) + + # Enforce single-entry rule + service_change_count = len(added) + len(removed) + len(modified) + + if service_change_count > 1: + print(red("Single-entry rule violation: PRs must contain only one service change."), file=sys.stderr) + print(red(f"Found {service_change_count} service-level changes:"), file=sys.stderr) + for svc in added: + print(f" + Added: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) + for svc in removed: + print(f" - Removed: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) + for svc in modified: + fields = ", ".join(svc["changed_fields"]) + print(f" ~ Modified: {svc['category']} > {svc['section']} > {svc['service']} ({fields})", file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + # If no service changes, check section-level changes + if service_change_count == 0 and len(section_changes) > 1: + print(red("Single-entry rule violation: PRs must contain only one section-level change."), file=sys.stderr) + print(red(f"Found {len(section_changes)} section-level changes:"), file=sys.stderr) + for change in section_changes: + detail = change["change_type"].replace("_", " ") + fields = change.get("changed_fields", []) + extra = f" ({', '.join(fields)})" if fields else "" + print(f" ~ {change['category']} > {change['section']}: {detail}{extra}", file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + # Summary + total = service_change_count + len(section_changes) + len(category_changes) + if total == 0: + print(green("No changes detected in awesome-privacy.yml")) + else: + print(green(f"Single-entry rule passed. {service_change_count} service change(s), " + f"{len(section_changes)} section change(s), {len(category_changes)} category change(s).")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py new file mode 100644 index 0000000..9b87e78 --- /dev/null +++ b/lib/checks/detect-changes.py @@ -0,0 +1,50 @@ +""" +Detects which files changed between the PR base and HEAD. +Sets GitHub Actions outputs: yaml_changed, non_yaml_changed. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + + +def write_github_output(name, value): + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def main(): + parser = argparse.ArgumentParser(description="Detect changed files in a PR") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + + print("Changed files:") + for f in changed_files: + print(f" {f}") + + yaml_changed = YAML_FILE in changed_files + non_yaml_changed = any(f != YAML_FILE for f in changed_files) + + write_github_output("yaml_changed", str(yaml_changed).lower()) + write_github_output("non_yaml_changed", str(non_yaml_changed).lower()) + + print(f"yaml_changed={yaml_changed}, non_yaml_changed={non_yaml_changed}") + + +if __name__ == "__main__": + main() diff --git a/lib/checks/warn-non-yaml.py b/lib/checks/warn-non-yaml.py new file mode 100644 index 0000000..e244dbc --- /dev/null +++ b/lib/checks/warn-non-yaml.py @@ -0,0 +1,58 @@ +""" +Warns when a PR modifies files other than awesome-privacy.yml. +This is expected for Website Update or Misc PRs, but may need extra review. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + + +def main(): + parser = argparse.ArgumentParser(description="Warn about non-YAML file changes") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + non_yaml = [f for f in changed_files if f != YAML_FILE] + + if not non_yaml: + return + + print(yellow("This PR modifies files other than awesome-privacy.yml:"), file=sys.stderr) + for f in non_yaml: + print(f" {f}", file=sys.stderr) + + # Write step summary + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_file: + lines = [ + "## Non-YAML Changes Warning\n", + "This PR modifies files other than `awesome-privacy.yml`:\n", + ] + for f in non_yaml: + lines.append(f"- `{f}`") + lines.append("") + lines.append("> **Note:** Most PRs should only modify `awesome-privacy.yml`. " + "Non-YAML changes may require additional review.\n") + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/lib/requirements.txt b/lib/requirements.txt index 3e298b1..f59b908 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,2 +1,3 @@ PyYAML==6.0.1 jsonschema==4.23.0 +requests==2.32.3 diff --git a/lib/schema.json b/lib/schema.json index d77f6a3..2ca04d2 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -7,38 +7,64 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, + "name": { "type": "string", "minLength": 1, "maxLength": 50 }, "sections": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, "services": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" }, - "github": { "type": ["string", "null"] }, - "icon": { "type": ["string", "null"] }, - "followWith": { "type": ["string", "null"] }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 10, "maxLength": 1500 }, + "url": { + "type": "string", + "anyOf": [ + { "pattern": "^https?://" }, + { "maxLength": 0 } + ] + }, + "github": { + "type": ["string", "null"], + "pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$" + }, + "icon": { + "type": ["string", "null"], + "pattern": "^https?://.+" + }, + "followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 }, "securityAudited": { "type": ["boolean", "null"] }, "openSource": { "type": ["boolean", "null"] }, "acceptsCrypto": { "type": ["boolean", "null"] }, - "tosdrId": { "type": ["number", "null"] }, - "iosApp": { "type": ["string", "null"] }, - "androidApp": { "type": ["string", "null"] }, - "discordInvite": { "type": ["string", "null"] }, - "subreddit": { "type": ["string", "null"] } + "tosdrId": { "type": ["integer", "null"], "minimum": 1 }, + "iosApp": { + "type": ["string", "null"], + "pattern": "^https://apps\\.apple\\.com/" + }, + "androidApp": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$" + }, + "discordInvite": { + "type": ["string", "null"], + "pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$" + }, + "subreddit": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z0-9_]+$", + "minLength": 1, + "maxLength": 50 + } }, "required": ["name", "description", "url"], "additionalProperties": false } }, - "intro": { "type": ["string", "null"] }, + "intro": { "type": ["string", "null"], "minLength": 1 }, "notableMentions": { "oneOf": [ { @@ -46,25 +72,26 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" } + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "pattern": "^https?://" } }, "required": ["name", "url"], "additionalProperties": false } }, - { "type": "string" }, + { "type": "string", "minLength": 1 }, { "type": "null" } ] }, - "furtherInfo": { "type": ["string", "null"] }, - "wordOfWarning": { "type": ["string", "null"] }, + "furtherInfo": { "type": ["string", "null"], "minLength": 1 }, + "wordOfWarning": { "type": ["string", "null"], "minLength": 1 }, "alternativeTo": { "oneOf": [ { "type": "array", - "items": { "type": "string" } + "items": { "type": "string", "minLength": 1, "maxLength": 100 }, + "minItems": 1 }, { "type": "null" } ]