mirror of
https://github.com/Lissy93/awesome-privacy.git
synced 2026-03-11 08:55:33 +00:00
Adds PR checks
This commit is contained in:
parent
2fec6f757d
commit
adc0df53a5
16 changed files with 1269 additions and 21 deletions
125
.github/workflows/pr-check.yml
vendored
Normal file
125
.github/workflows/pr-check.yml
vendored
Normal file
|
|
@ -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'
|
||||
<!-- pr-check-bot -->
|
||||
> [!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/
|
||||
105
.github/workflows/pr-comment.yml
vendored
Normal file
105
.github/workflows/pr-comment.yml
vendored
Normal file
|
|
@ -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 = '<!-- pr-check-bot -->';
|
||||
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,
|
||||
});
|
||||
}
|
||||
BIN
lib/checks/__pycache__/check-links.cpython-310.pyc
Normal file
BIN
lib/checks/__pycache__/check-links.cpython-310.pyc
Normal file
Binary file not shown.
BIN
lib/checks/__pycache__/check-readme-edits.cpython-310.pyc
Normal file
BIN
lib/checks/__pycache__/check-readme-edits.cpython-310.pyc
Normal file
Binary file not shown.
BIN
lib/checks/__pycache__/check-template.cpython-310.pyc
Normal file
BIN
lib/checks/__pycache__/check-template.cpython-310.pyc
Normal file
Binary file not shown.
BIN
lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc
Normal file
BIN
lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc
Normal file
Binary file not shown.
BIN
lib/checks/__pycache__/detect-changes.cpython-310.pyc
Normal file
BIN
lib/checks/__pycache__/detect-changes.cpython-310.pyc
Normal file
Binary file not shown.
BIN
lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc
Normal file
BIN
lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc
Normal file
Binary file not shown.
211
lib/checks/check-links.py
Normal file
211
lib/checks/check-links.py
Normal file
|
|
@ -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()
|
||||
123
lib/checks/check-readme-edits.py
Normal file
123
lib/checks/check-readme-edits.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
Fails if the PR directly edits the auto-generated section of the README.
|
||||
The generated section is between <!-- awesome-privacy-start --> and <!-- awesome-privacy-end -->.
|
||||
"""
|
||||
|
||||
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 "<!-- awesome-privacy-start -->" in line:
|
||||
start_line = i
|
||||
if "<!-- awesome-privacy-end -->" 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 `<!-- awesome-privacy-start -->` and `<!-- awesome-privacy-end -->`).\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()
|
||||
206
lib/checks/check-template.py
Normal file
206
lib/checks/check-template.py
Normal file
|
|
@ -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()
|
||||
342
lib/checks/check-yaml-diff.py
Normal file
342
lib/checks/check-yaml-diff.py
Normal file
|
|
@ -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()
|
||||
50
lib/checks/detect-changes.py
Normal file
50
lib/checks/detect-changes.py
Normal file
|
|
@ -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()
|
||||
58
lib/checks/warn-non-yaml.py
Normal file
58
lib/checks/warn-non-yaml.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
PyYAML==6.0.1
|
||||
jsonschema==4.23.0
|
||||
requests==2.32.3
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue