From 2476a57bac22dd31a6ba7bd9f27ec0e4e38d86d8 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Mon, 23 Feb 2026 15:16:57 +0000 Subject: [PATCH] Updates check workflow, to review pull requests --- .github/workflows/pr-check.yml | 190 +++++++++++---------------- .github/workflows/pr-comment.yml | 11 +- lib/checks/check-additions.py | 173 ++++++++++++++++++++++++ lib/checks/check-pr-meta.py | 122 +++++++++++++++++ lib/checks/check-project.py | 217 +++++++++++++++++++++++++++++++ lib/checks/format-comment.py | 106 +++++++++++++++ 6 files changed, 700 insertions(+), 119 deletions(-) create mode 100644 lib/checks/check-additions.py create mode 100644 lib/checks/check-pr-meta.py create mode 100644 lib/checks/check-project.py create mode 100644 lib/checks/format-comment.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 94095b5..4e56533 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -18,13 +18,10 @@ jobs: non_yaml_changed: ${{ steps.changes.outputs.non_yaml_changed }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - 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 }} @@ -32,54 +29,64 @@ jobs: if: steps.changes.outputs.non_yaml_changed == 'true' run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} - readme: - name: README edits + pr-meta: + name: PR metadata runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - fetch-depth: 0 + python-version: "3.12" + - name: Check PR metadata + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + run: python lib/checks/check-pr-meta.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-meta + path: /tmp/findings-meta.json + if-no-files-found: ignore + + file-checks: + name: File checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - 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: Check for direct README edits run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} - schema: - name: Schema validation + data-validation: + name: Data validation needs: detect-changes if: needs.detect-changes.outputs.yaml_changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - - name: Validate schema - run: make validate - - diff: - name: Single-entry rule - needs: detect-changes - if: needs.detect-changes.outputs.yaml_changed == 'true' - runs-on: ubuntu-latest - outputs: - has_service_changes: ${{ steps.diff.outputs.has_service_changes }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - 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: YAML diff analysis + - name: Schema validation + id: schema + continue-on-error: true + run: make validate + - name: YAML diff id: diff + continue-on-error: true run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Check additions + env: + SCHEMA_OUTCOME: ${{ steps.schema.outcome }} + run: python lib/checks/check-additions.py - name: Upload diff data if: always() uses: actions/upload-artifact@v4 @@ -87,11 +94,21 @@ jobs: name: pr-diff path: /tmp/pr-diff.json if-no-files-found: ignore + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-data + path: /tmp/findings-data.json + if-no-files-found: ignore + - name: Fail if critical checks failed + if: steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure' + run: exit 1 - links: - name: Link validation - needs: diff - if: needs.diff.result == 'success' && needs.diff.outputs.has_service_changes == 'true' + project-checks: + name: Project checks + needs: data-validation + if: "!cancelled() && needs.data-validation.result != 'skipped'" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -104,93 +121,44 @@ jobs: with: name: pr-diff path: /tmp - - name: Validate links - run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + continue-on-error: true + - name: Check project health + env: + PR_USER: ${{ github.event.pull_request.user.login }} + GITHUB_TOKEN: ${{ github.token }} + run: python lib/checks/check-project.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-project + path: /tmp/findings-project.json + if-no-files-found: ignore - template: - name: PR template + summary: + name: Summary + if: always() + needs: [detect-changes, pr-meta, file-checks, data-validation, project-checks] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -q -r lib/requirements.txt - - name: PR template check + - name: Download all findings + uses: actions/download-artifact@v4 + with: + pattern: findings-* + path: /tmp/artifacts + merge-multiple: true + continue-on-error: true + - name: Format comment env: - PR_BODY: ${{ github.event.pull_request.body }} - run: python lib/checks/check-template.py - - comment: - name: Summary - if: always() - needs: [readme, schema, diff, links, template] - runs-on: ubuntu-latest - steps: - - name: Save PR metadata - 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: contains(needs.*.result, 'failure') - run: | - cat > /tmp/pr-meta/comment.md << 'HEADER' - - > [!CAUTION] - > ## PR check failed - HEADER - - if [ "${{ needs.readme.result }}" = "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 [ "${{ needs.schema.result }}" = "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 [ "${{ needs.diff.result }}" = "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 [ "${{ needs.links.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### Link validation failed - One or more URLs in your entry could not be verified. - Please ensure all links are correct and accessible. - EOF - fi - - if [ "${{ needs.template.result }}" = "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 - - 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 - + PR_USER: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_ID: ${{ github.run_id }} + README_FAILED: ${{ needs.file-checks.result == 'failure' && 'true' || 'false' }} + run: python lib/checks/format-comment.py - name: Upload PR metadata if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 4034dba..455112c 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -33,7 +33,6 @@ jobs: script: | const fs = require('fs'); const marker = ''; - const conclusion = context.payload.workflow_run.conclusion; // Determine the PR number let prNumber; @@ -73,12 +72,8 @@ jobs: }); 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 commentFile = 'pr-meta/comment.md'; + if (fs.existsSync(commentFile)) { const body = fs.readFileSync(commentFile, 'utf8').trim(); if (existing) { await github.rest.issues.updateComment({ @@ -96,7 +91,7 @@ jobs: }); } } else if (existing) { - // Checks passed — remove stale failure comment + // No findings — remove stale comment await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py new file mode 100644 index 0000000..474c651 --- /dev/null +++ b/lib/checks/check-additions.py @@ -0,0 +1,173 @@ +"""Validates data quality for added/modified services using the diff JSON.""" + +import json +import os +import sys + +import yaml + +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_PATH = "/tmp/pr-diff.json" +FINDINGS_PATH = "/tmp/findings-data.json" + +REQUIRED_FIELDS = ("name", "description", "url", "icon") + +CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" + +SCHEMA_MSG = ( + "Some of the schema checks have failed. Please check that your addition" + " contains all the required fields, with acceptable values, nothing" + " additional and that it is following valid YAML syntax" +) +MULTIPLE_MSG = "Please make just one addition per pull request" +MISSING_TPL = ( + "Did you include all required fields? Looks like {fields} is missing or" + f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)" + " for available fields." +) +POSITION_MSG = ( + "New entries must be added to the end of the section, unless otherwise requested" +) +OPENSOURCE_MSG = ( + "You indicated this app/service is not open source. This will likely make" + " it ineligible for listing on Awesome Privacy in accordance with our" + f" [Requirements]({CONTRIBUTING}#requirements)." + " Please ensure that this is justified in your PR body." +) + + +def load_json(path): + """Load JSON from a file, returning None on any error.""" + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def load_yaml_data(path): + """Load YAML from a file, returning None on any error.""" + try: + with open(path) as f: + return yaml.safe_load(f) + except Exception: + return None + + +def find_section_services(head, category, section): + """Return the services list for a category/section pair, or None.""" + for cat in head.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + return sec.get("services", []) + return None + + +def find_service_fields(head, category, section, service_name): + """Look up a service's fields in the head YAML.""" + services = find_section_services(head, category, section) + if services: + for svc in services: + if svc.get("name") == service_name: + return svc + return None + + +def check_required_fields(diff, head): + """Return a finding if any added/modified service is missing required fields.""" + missing = set() + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + for f in REQUIRED_FIELDS: + if not fields.get(f): + missing.add(f) + for svc in diff.get("services", {}).get("modified", []): + if not head: + continue + fields = find_service_fields( + head, svc["category"], svc["section"], svc["service"] + ) + if fields: + for f in REQUIRED_FIELDS: + if not fields.get(f): + missing.add(f) + if missing: + names = ", ".join(f"`{f}`" for f in sorted(missing)) + return MISSING_TPL.format(fields=names) + return None + + +def check_position(diff, head): + """Return a finding if a newly added service is not at the end of its section.""" + if not head: + return None + for svc in diff.get("services", {}).get("added", []): + services = find_section_services(head, svc["category"], svc["section"]) + if services and services[-1].get("name") != svc["service"]: + return POSITION_MSG + return None + + +def check_open_source(diff): + """Return a finding if an added service has openSource missing or not true.""" + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + if fields.get("openSource") is not True: + return OPENSOURCE_MSG + return None + + +def check_single_entry(diff): + """Return a finding if the diff contains multiple service or section changes.""" + services = diff.get("services", {}) + svc_count = ( + len(services.get("added", [])) + + len(services.get("removed", [])) + + len(services.get("modified", [])) + ) + if svc_count > 1: + return MULTIPLE_MSG + if svc_count == 0: + sec_count = len(diff.get("sections", [])) + if sec_count > 1: + return MULTIPLE_MSG + return None + + +def main(): + findings = [] + try: + if os.environ.get("SCHEMA_OUTCOME") == "failure": + findings.append(SCHEMA_MSG) + + diff = load_json(DIFF_PATH) + head = load_yaml_data(DATA_PATH) + + if diff: + finding = check_single_entry(diff) + if finding: + findings.append(finding) + + finding = check_required_fields(diff, head) + if finding: + findings.append(finding) + + finding = check_position(diff, head) + if finding: + findings.append(finding) + + finding = check_open_source(diff) + if finding: + findings.append(finding) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-pr-meta.py b/lib/checks/check-pr-meta.py new file mode 100644 index 0000000..f63f561 --- /dev/null +++ b/lib/checks/check-pr-meta.py @@ -0,0 +1,122 @@ +"""Checks PR metadata: title format, draft status, template completeness, and checkboxes.""" + +import json +import os +import re +import sys + +FINDINGS_PATH = "/tmp/findings-meta.json" + +BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"} + +TITLE_MSG = ( + "The pull request title does not follow the format defined in our guidelines." + " Please rename it to `[Add/Remove/Update] [software name] in [software section]`" +) +DRAFT_MSG = ( + "Please avoid opening WIP pull requests." + " Your PR should be 100% ready and complete before submitting" +) +TEMPLATE_MSG = ( + "Please fill in pull request template in full." + " You can find a copy of this" + " [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)" +) +CHECKBOX_MSG = ( + "Ensure you have completed the checklist (put a tick the checkboxes with `[x]`)," + " to confirm that you've read the contributing guidelines, checked your submission," + " indicated your affiliation and agree to follow our CoC" +) + + +def extract_section(body, header): + """Extract content between a ### header and the next delimiter.""" + pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" + match = re.search(pattern, body, re.DOTALL) + return match.group(1) if match else None + + +def strip_html_comments(text): + """Remove HTML comments from text.""" + return re.sub(r"", "", text, flags=re.DOTALL).strip() + + +def check_title(title): + """Return a finding if the PR title matches a known-bad pattern.""" + if title and title.strip().lower() in BAD_TITLES: + return TITLE_MSG + return None + + +def check_draft(draft_str): + """Return a finding if the PR is in draft state.""" + if str(draft_str).lower() == "true": + return DRAFT_MSG + return None + + +def check_template(body): + """Return a finding if required template sections are missing or empty.""" + for header in ("Type", "Changes", "Checklist"): + content = extract_section(body, header) + if content is None or not strip_html_comments(content): + return TEMPLATE_MSG + return None + + +def check_checkboxes(body): + """Return a finding if any checklist checkboxes are unchecked.""" + section = extract_section(body, "Checklist") + if section is None: + return None + checked = re.findall(r"- \[x\]", section, re.IGNORECASE) + unchecked = re.findall(r"- \[ \]", section) + if not checked and not unchecked: + return None + if unchecked: + return CHECKBOX_MSG + return None + + +def write_findings(findings): + """Write the findings list to the output JSON file.""" + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + + +def main(): + findings = [] + critical = False + try: + title = os.environ.get("PR_TITLE", "") + body = os.environ.get("PR_BODY", "") + draft = os.environ.get("PR_DRAFT", "false") + + finding = check_title(title) + if finding: + findings.append(finding) + + finding = check_draft(draft) + if finding: + findings.append(finding) + + if not body or not body.strip(): + findings.append(TEMPLATE_MSG) + critical = True + else: + finding = check_template(body) + if finding: + findings.append(finding) + critical = True + finding = check_checkboxes(body) + if finding: + findings.append(finding) + except Exception: + pass + + write_findings(findings) + sys.exit(1 if critical else 0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py new file mode 100644 index 0000000..ffa78db --- /dev/null +++ b/lib/checks/check-project.py @@ -0,0 +1,217 @@ +"""Checks project health: URL reachability, GitHub repo stars, activity, and author match.""" + +import json +import os +import sys +from datetime import datetime, timezone + +import requests +import yaml + +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_PATH = "/tmp/pr-diff.json" +FINDINGS_PATH = "/tmp/findings-project.json" + +TIMEOUT = 10 +USER_AGENT = "awesome-privacy-ci/1.0" +MIN_STARS = 100 +INACTIVE_DAYS = 90 + +LINK_MSG = ( + "Our automated checks were unable to verify the link(s) you included" + " were reachable, so please double check this yourself" +) +AUTHOR_MSG = ( + "Looks like you are the author of this package. Please ensure that you" + " have clearly disclosed this in your PR body for transparency" +) +STARS_MSG = ( + "It looks like your submission is adding a quite small project." + " In some circumstances we may ask you to resubmit this once the project" + " is more mature and has a proven track record of good practices and maintenance." +) +ACTIVITY_MSG = ( + "Please confirm that the project you are adding is actively maintained," + " as it looks to not have had any recent updates in the past 3 months." +) + + +def load_diff(path): + """Load the diff JSON, returning None on any error.""" + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def check_url(url): + """Return True if the URL is reachable, True on any error (no false positives).""" + try: + resp = requests.head( + url, timeout=TIMEOUT, allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + if resp.status_code >= 400: + resp = requests.get( + url, timeout=TIMEOUT, allow_redirects=True, + headers={"User-Agent": USER_AGENT}, stream=True, + ) + resp.close() + return resp.status_code < 400 + except Exception: + return True + + +def parse_github_field(value): + """Parse a github field into (owner, repo), or (None, None) on failure.""" + if not value: + return None, None + if value.startswith("https://github.com/"): + parts = value.removeprefix("https://github.com/").strip("/").split("/") + if len(parts) >= 2: + return parts[0], parts[1] + return None, None + if "/" in value: + parts = value.split("/") + if len(parts) == 2: + return parts[0], parts[1] + return None, None + + +def fetch_repo(owner, repo, token): + """Fetch GitHub repo metadata, returning None on any error.""" + try: + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT} + if token: + headers["Authorization"] = f"token {token}" + resp = requests.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, timeout=TIMEOUT, + ) + if resp.status_code == 200: + return resp.json() + except Exception: + pass + return None + + +def load_yaml_data(): + """Load the head YAML, returning None on any error.""" + try: + with open(DATA_PATH) as f: + return yaml.safe_load(f) + except Exception: + return None + + +def find_service_in_head(head, category, section, service_name): + """Look up a service in the head YAML by path.""" + if not head: + return None + for cat in head.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 + return None + + +def get_services(diff, key): + """Safely extract a service list from the diff.""" + return diff.get("services", {}).get(key, []) + + +def check_links(diff, head): + """Return LINK_MSG if any service URL is unreachable.""" + for svc in get_services(diff, "added"): + url = svc.get("fields", {}).get("url") + if url and not check_url(url): + return LINK_MSG + for svc in get_services(diff, "modified"): + if "url" not in svc.get("changed_fields", []): + continue + head_svc = find_service_in_head( + head, svc["category"], svc["section"], svc["service"] + ) + if head_svc: + url = head_svc.get("url") + if url and not check_url(url): + return LINK_MSG + return None + + +def check_repo_signals(diff, pr_user, token): + """Check GitHub repo author match, stars, and activity for added services.""" + findings = [] + if not token: + return findings + cache = {} + for svc in get_services(diff, "added"): + gh = svc.get("fields", {}).get("github") + owner, repo = parse_github_field(gh) + if not owner: + continue + cache_key = f"{owner}/{repo}" + if cache_key not in cache: + cache[cache_key] = fetch_repo(owner, repo, token) + data = cache[cache_key] + if not data: + continue + + repo_owner = data.get("owner", {}) + if ( + pr_user + and repo_owner.get("type") == "User" + and repo_owner.get("login", "").lower() == pr_user.lower() + and AUTHOR_MSG not in findings + ): + findings.append(AUTHOR_MSG) + + stars = data.get("stargazers_count", 0) + if stars < MIN_STARS and STARS_MSG not in findings: + findings.append(STARS_MSG) + + pushed = data.get("pushed_at") + if pushed and ACTIVITY_MSG not in findings: + try: + pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + if (now - pushed_dt).days > INACTIVE_DAYS: + findings.append(ACTIVITY_MSG) + except Exception: + pass + + return findings + + +def main(): + findings = [] + try: + diff = load_diff(DIFF_PATH) + if not diff: + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + head = load_yaml_data() + finding = check_links(diff, head) + if finding: + findings.append(finding) + + pr_user = os.environ.get("PR_USER", "") + token = os.environ.get("GITHUB_TOKEN", "") + findings.extend(check_repo_signals(diff, pr_user, token)) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py new file mode 100644 index 0000000..e81497a --- /dev/null +++ b/lib/checks/format-comment.py @@ -0,0 +1,106 @@ +"""Aggregates findings from all check jobs into a formatted PR comment.""" + +import json +import os +import sys + +ARTIFACTS_DIR = "/tmp/artifacts" +OUTPUT_DIR = "/tmp/pr-meta" + +README_MSG = ( + "Do not edit the README directly. This file is auto-generated from the" + " content in `awesome-privacy.yml`, and so your changes will be overridden!" + " Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines." +) + +CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" + +COMMENT_TEMPLATE = """ +Hello @{user} + +Thank you for contributing to Awesome Privacy! We will review your PR shortly. In the meantime, please ensure that your submission is inline with our guidelines in our [Contributing Requirements]({contributing}). + +Looks like there could be some issues in your PR. Please double check that: + +{findings} + +> [!NOTE] +> I am a bot, and sometimes make mistakes in my suggestions. But a human will review your submission shortly!""" + + +def load_findings(filename): + """Load a findings JSON array from the artifacts directory, or empty list on error.""" + try: + with open(os.path.join(ARTIFACTS_DIR, filename)) as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def collect_findings(readme_failed): + """Gather all findings in display order: meta, readme, data, project.""" + all_findings = [] + all_findings.extend(load_findings("findings-meta.json")) + if readme_failed: + all_findings.append(README_MSG) + all_findings.extend(load_findings("findings-data.json")) + all_findings.extend(load_findings("findings-project.json")) + return all_findings + + +def format_comment(findings, user): + """Build the markdown comment from findings.""" + bullet_list = "\n".join(f"- {f}" for f in findings) + return COMMENT_TEMPLATE.format( + user=user, contributing=CONTRIBUTING, findings=bullet_list, + ) + + +def write_step_summary(findings): + """Write a summary to GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + lines = ["## PR Check Summary\n"] + if findings: + lines.append(f"⚠️ Found {len(findings)} issue(s):\n") + for f in findings: + lines.append(f"- {f}") + else: + lines.append("✅ All checks passed.\n") + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + try: + user = os.environ.get("PR_USER", "contributor") + pr_number = os.environ.get("PR_NUMBER", "") + run_id = os.environ.get("RUN_ID", "") + readme_failed = os.environ.get("README_FAILED", "false") == "true" + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + if pr_number: + with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f: + f.write(pr_number) + if run_id: + with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f: + f.write(run_id) + + findings = collect_findings(readme_failed) + write_step_summary(findings) + + if findings: + comment = format_comment(findings, user) + with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f: + f.write(comment) + except Exception: + pass + + sys.exit(0) + + +if __name__ == "__main__": + main()