mirror of
https://github.com/Lissy93/awesome-privacy.git
synced 2026-03-11 08:55:33 +00:00
Updates check workflow, to review pull requests
This commit is contained in:
parent
66d9970144
commit
2476a57bac
6 changed files with 700 additions and 119 deletions
190
.github/workflows/pr-check.yml
vendored
190
.github/workflows/pr-check.yml
vendored
|
|
@ -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'
|
||||
<!-- pr-check-bot -->
|
||||
> [!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
|
||||
|
|
|
|||
11
.github/workflows/pr-comment.yml
vendored
11
.github/workflows/pr-comment.yml
vendored
|
|
@ -33,7 +33,6 @@ jobs:
|
|||
script: |
|
||||
const fs = require('fs');
|
||||
const marker = '<!-- pr-check-bot -->';
|
||||
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,
|
||||
|
|
|
|||
173
lib/checks/check-additions.py
Normal file
173
lib/checks/check-additions.py
Normal file
|
|
@ -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()
|
||||
122
lib/checks/check-pr-meta.py
Normal file
122
lib/checks/check-pr-meta.py
Normal file
|
|
@ -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()
|
||||
217
lib/checks/check-project.py
Normal file
217
lib/checks/check-project.py
Normal file
|
|
@ -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()
|
||||
106
lib/checks/format-comment.py
Normal file
106
lib/checks/format-comment.py
Normal file
|
|
@ -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 = """<!-- pr-check-bot -->
|
||||
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()
|
||||
Loading…
Reference in a new issue