mirror of
https://github.com/wilsonfreitas/awesome-quant.git
synced 2026-11-02 04:16:10 +00:00
ci: schedule weekly README audit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
name: README Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: readme-audit
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --no-install-project
|
||||
- name: Audit README links and repositories
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: uv run python scripts/audit_readme.py --readme README.md
|
||||
+38
-1
@@ -2,8 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -13,7 +16,11 @@ from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from github import GithubException
|
||||
from github import Auth, Github, GithubException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.readme_entries import (
|
||||
HISTORICAL_SECTION,
|
||||
@@ -488,3 +495,33 @@ def audit_readme(
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit awesome-quant README links and repositories."
|
||||
)
|
||||
parser.add_argument("--readme", default="README.md")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("GITHUB_TOKEN is required")
|
||||
repository_name = os.environ.get("GITHUB_REPOSITORY", "").strip()
|
||||
if not repository_name:
|
||||
raise RuntimeError("GITHUB_REPOSITORY is required")
|
||||
|
||||
github_client = Github(auth=Auth.Token(token))
|
||||
repository = github_client.get_repo(repository_name)
|
||||
findings = audit_readme(args.readme, github_client)
|
||||
body = render_report(findings, checked_at=datetime.now(timezone.utc))
|
||||
action = sync_tracking_issue(repository, findings, body)
|
||||
print(f"README audit: {len(findings)} finding(s); issue action: {action}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
+10
-5
@@ -58,12 +58,16 @@ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
||||
self._server_hostname = server_hostname
|
||||
|
||||
def connect(self) -> None:
|
||||
self.sock = self._create_connection(
|
||||
(self.host, self.port), self.timeout, self.source_address
|
||||
self.sock = self._create_connection( # type: ignore[attr-defined]
|
||||
(self.host, self.port),
|
||||
self.timeout,
|
||||
self.source_address, # type: ignore[attr-defined]
|
||||
)
|
||||
if self._tunnel_host: # type: ignore[attr-defined]
|
||||
self._tunnel() # type: ignore[attr-defined]
|
||||
self.sock = self._context.wrap_socket( # type: ignore[attr-defined]
|
||||
self.sock, server_hostname=self._server_hostname
|
||||
)
|
||||
if self._tunnel_host:
|
||||
self._tunnel()
|
||||
self.sock = self._context.wrap_socket(self.sock, server_hostname=self._server_hostname)
|
||||
|
||||
|
||||
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
||||
@@ -130,6 +134,7 @@ def _single_request(
|
||||
address: str,
|
||||
timeout: float,
|
||||
) -> RawResponse:
|
||||
connection: http.client.HTTPConnection
|
||||
if parsed.scheme.lower() == "https":
|
||||
connection = _PinnedHTTPSConnection(address, port, hostname, timeout)
|
||||
else:
|
||||
|
||||
+210
-1
@@ -1,11 +1,16 @@
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from github import GithubException
|
||||
|
||||
@@ -18,6 +23,8 @@ from scripts.audit_readme import (
|
||||
TRACKING_TITLE,
|
||||
audit_readme,
|
||||
collect_targets,
|
||||
main,
|
||||
parse_args,
|
||||
render_report,
|
||||
sync_tracking_issue,
|
||||
)
|
||||
@@ -131,6 +138,208 @@ class FakeTrackingRepository:
|
||||
return issue
|
||||
|
||||
|
||||
class AuditReadmeMainTests(unittest.TestCase):
|
||||
def test_direct_cli_help_works_without_github_environment(self):
|
||||
environment = os.environ.copy()
|
||||
environment.pop("GITHUB_TOKEN", None)
|
||||
environment.pop("GITHUB_REPOSITORY", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/audit_readme.py", "--help"],
|
||||
cwd=Path(__file__).parents[1],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("--readme", result.stdout)
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
def test_parse_args_defaults_to_readme(self):
|
||||
with patch("sys.argv", ["audit_readme.py"]):
|
||||
args = parse_args()
|
||||
|
||||
self.assertEqual(args.readme, "README.md")
|
||||
|
||||
def test_parse_args_accepts_readme_path(self):
|
||||
with patch("sys.argv", ["audit_readme.py", "--readme", "docs/list.md"]):
|
||||
args = parse_args()
|
||||
|
||||
self.assertEqual(args.readme, "docs/list.md")
|
||||
|
||||
def test_main_rejects_missing_or_blank_environment_variables(self):
|
||||
cases = (
|
||||
({"GITHUB_REPOSITORY": "owner/list"}, "GITHUB_TOKEN is required"),
|
||||
(
|
||||
{"GITHUB_TOKEN": " \t", "GITHUB_REPOSITORY": "owner/list"},
|
||||
"GITHUB_TOKEN is required",
|
||||
),
|
||||
({"GITHUB_TOKEN": "secret"}, "GITHUB_REPOSITORY is required"),
|
||||
(
|
||||
{"GITHUB_TOKEN": "secret", "GITHUB_REPOSITORY": " "},
|
||||
"GITHUB_REPOSITORY is required",
|
||||
),
|
||||
)
|
||||
for environment, message in cases:
|
||||
with self.subTest(message=message, environment=environment):
|
||||
with (
|
||||
patch.dict("os.environ", environment, clear=True),
|
||||
patch("scripts.audit_readme.parse_args"),
|
||||
patch("scripts.audit_readme.Github") as github,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, f"^{message}$"):
|
||||
main()
|
||||
|
||||
github.assert_not_called()
|
||||
|
||||
def test_main_publishes_findings_and_returns_zero_with_one_repo_lookup(self):
|
||||
token = "do-not-print-this-token"
|
||||
repository = object()
|
||||
client = SimpleNamespace(get_repo=Mock(return_value=repository))
|
||||
finding = SimpleNamespace()
|
||||
checked_at = datetime(2026, 9, 7, 9, 0, tzinfo=timezone.utc)
|
||||
stdout = io.StringIO()
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"GITHUB_TOKEN": f" {token} ",
|
||||
"GITHUB_REPOSITORY": " owner/list ",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch(
|
||||
"scripts.audit_readme.parse_args",
|
||||
return_value=SimpleNamespace(readme="custom.md"),
|
||||
),
|
||||
patch("scripts.audit_readme.Auth.Token", return_value="auth") as auth_token,
|
||||
patch("scripts.audit_readme.Github", return_value=client) as github,
|
||||
patch(
|
||||
"scripts.audit_readme.audit_readme",
|
||||
return_value=[finding],
|
||||
) as audit,
|
||||
patch(
|
||||
"scripts.audit_readme.render_report",
|
||||
return_value="report body",
|
||||
) as render,
|
||||
patch(
|
||||
"scripts.audit_readme.sync_tracking_issue",
|
||||
return_value="created",
|
||||
) as sync,
|
||||
patch("scripts.audit_readme.datetime") as clock,
|
||||
redirect_stdout(stdout),
|
||||
):
|
||||
clock.now.return_value = checked_at
|
||||
result = main()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
auth_token.assert_called_once_with(token)
|
||||
github.assert_called_once_with(auth="auth")
|
||||
client.get_repo.assert_called_once_with("owner/list")
|
||||
audit.assert_called_once_with("custom.md", client)
|
||||
clock.now.assert_called_once_with(timezone.utc)
|
||||
render.assert_called_once_with([finding], checked_at=checked_at)
|
||||
sync.assert_called_once_with(repository, [finding], "report body")
|
||||
self.assertEqual(stdout.getvalue(), "README audit: 1 finding(s); issue action: created\n")
|
||||
self.assertNotIn(token, stdout.getvalue())
|
||||
|
||||
def test_main_returns_zero_for_multiple_findings_after_publication(self):
|
||||
findings = [SimpleNamespace(), SimpleNamespace()]
|
||||
client = SimpleNamespace(get_repo=lambda _name: object())
|
||||
stdout = io.StringIO()
|
||||
with (
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"GITHUB_TOKEN": "token", "GITHUB_REPOSITORY": "owner/list"},
|
||||
clear=True,
|
||||
),
|
||||
patch(
|
||||
"scripts.audit_readme.parse_args",
|
||||
return_value=SimpleNamespace(readme="README.md"),
|
||||
),
|
||||
patch("scripts.audit_readme.Github", return_value=client),
|
||||
patch("scripts.audit_readme.audit_readme", return_value=findings),
|
||||
patch("scripts.audit_readme.render_report", return_value="body"),
|
||||
patch("scripts.audit_readme.sync_tracking_issue", return_value="updated"),
|
||||
redirect_stdout(stdout),
|
||||
):
|
||||
result = main()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(
|
||||
stdout.getvalue(),
|
||||
"README audit: 2 finding(s); issue action: updated\n",
|
||||
)
|
||||
|
||||
def test_main_propagates_repository_lookup_error(self):
|
||||
api_error = RuntimeError("API failed")
|
||||
client = SimpleNamespace(get_repo=Mock(side_effect=api_error))
|
||||
with (
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"GITHUB_TOKEN": "token", "GITHUB_REPOSITORY": "owner/list"},
|
||||
clear=True,
|
||||
),
|
||||
patch(
|
||||
"scripts.audit_readme.parse_args",
|
||||
return_value=SimpleNamespace(readme="README.md"),
|
||||
),
|
||||
patch("scripts.audit_readme.Github", return_value=client),
|
||||
patch("scripts.audit_readme.audit_readme") as audit,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
main()
|
||||
|
||||
self.assertIs(caught.exception, api_error)
|
||||
client.get_repo.assert_called_once_with("owner/list")
|
||||
audit.assert_not_called()
|
||||
|
||||
def test_main_propagates_audit_and_publication_errors(self):
|
||||
audit_error = RuntimeError("audit failed")
|
||||
publication_error = RuntimeError("publication failed")
|
||||
cases = (
|
||||
("audit", audit_error, None),
|
||||
("publication", None, publication_error),
|
||||
)
|
||||
for name, audit_side_effect, sync_side_effect in cases:
|
||||
with self.subTest(name=name):
|
||||
client = SimpleNamespace(get_repo=lambda _name: object())
|
||||
with (
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"GITHUB_TOKEN": "token",
|
||||
"GITHUB_REPOSITORY": "owner/list",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch(
|
||||
"scripts.audit_readme.parse_args",
|
||||
return_value=SimpleNamespace(readme="README.md"),
|
||||
),
|
||||
patch("scripts.audit_readme.Github", return_value=client),
|
||||
patch(
|
||||
"scripts.audit_readme.audit_readme",
|
||||
return_value=[] if audit_side_effect is None else None,
|
||||
side_effect=audit_side_effect,
|
||||
),
|
||||
patch("scripts.audit_readme.render_report", return_value="body"),
|
||||
patch(
|
||||
"scripts.audit_readme.sync_tracking_issue",
|
||||
return_value="clean",
|
||||
side_effect=sync_side_effect,
|
||||
),
|
||||
):
|
||||
expected = audit_side_effect or sync_side_effect
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
main()
|
||||
|
||||
self.assertIs(caught.exception, expected)
|
||||
|
||||
|
||||
class AuditReadmeTests(unittest.TestCase):
|
||||
def test_render_report_is_a_complete_deterministic_markdown_document(self):
|
||||
findings = [
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WORKFLOW_PATH = Path(__file__).parents[1] / ".github/workflows/readme-audit.yml"
|
||||
|
||||
|
||||
class ReadmeAuditWorkflowTests(unittest.TestCase):
|
||||
def test_workflow_matches_the_scheduled_audit_contract(self):
|
||||
expected = """name: README Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: readme-audit
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --no-install-project
|
||||
- name: Audit README links and repositories
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: uv run python scripts/audit_readme.py --readme README.md
|
||||
"""
|
||||
|
||||
self.assertEqual(WORKFLOW_PATH.read_text(encoding="utf-8"), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user