From 95f1e1e93e3fcc2592efbb9d7ebaf1742ad55994 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 14:01:20 -0600 Subject: [PATCH 01/64] use uv and githubkit --- .env.example | 1 - .python-version | 2 +- app.py | 138 +++++++++++++++++++++++++++++++++++++++++ test_app.py | 161 ++++++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 127 +------------------------------------- 5 files changed, 301 insertions(+), 128 deletions(-) create mode 100644 app.py create mode 100644 test_app.py diff --git a/.env.example b/.env.example index 1e49e90..a0ff71c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ GH_CLIENT_ID="123456" GH_CLIENT_PRIVATE_KEY="base64...your...pem...keyfile" -GH_AUTH_TOKEN="ghp_123456" CVE_USERNAME="user@example.org" CVE_API_KEY="123456" CVE_ENV="testproddev" diff --git a/.python-version b/.python-version index 3767b4b..12566ed 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 \ No newline at end of file +3.14.0 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..2fa08ce --- /dev/null +++ b/app.py @@ -0,0 +1,138 @@ +"""GitHub application which applies the PSRT process for GitHub Security Advisories.""" + +import base64 +import datetime +import os +import typing + +from cvelib.cve_api import CveApi +from dotenv import load_dotenv +from githubkit import AppAuthStrategy, GitHub + +# Load environment variables from .env file +load_dotenv() + +if typing.TYPE_CHECKING: + pass + +PSRT_GITHUB_TEAM_SLUG = "psrt" + + +def get_repository_advisories( + github: GitHub, + owner: str, + repo: str, +) -> typing.Iterable[dict[str, typing.Any]]: + """Lists repository security advisories using the REST API.""" + from githubkit.exception import RequestFailed + import json + + try: + # Use direct request instead of paginate to avoid validation issues + response = github.rest.security_advisories.list_repository_advisories( + owner=owner, + repo=repo, + ) + # Parse JSON directly to bypass Pydantic validation + advisories = json.loads(response.content) + for advisory in advisories: + yield advisory + except RequestFailed as e: + # 404 means no advisories or no access - that's okay + if e.response.status_code == 404: + return + raise + + +def reserve_one_cve(cve_api: CveApi) -> str: + """Reserves a single CVE ID""" + resp = cve_api.reserve(count=1, random=True, year=str(datetime.date.today().year)) + cve_ids = [cve["cve_id"] for cve in resp["cve_ids"]] + assert len(cve_ids) == 1 + return cve_ids[0] + + +def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> None: + """Applies the PSRT GitHub Security Advisory process to the repository.""" + security_advisories = get_repository_advisories(github, owner, repo) + advisory_count = 0 + for security_advisory in security_advisories: + advisory_count += 1 + ghsa_id = security_advisory["ghsa_id"] + state = security_advisory["state"] + + # We only operate on in-progress security advisories. + if state not in ("triage", "draft"): + print(f" â­ī¸ Skipping {ghsa_id} (state: {state})") + continue + + print(f" 📋 Processing {ghsa_id} (state: {state})") + + # Maintain a dictionary of updates to make and then submit them all at once. + patch_data = {} + + # Advisories that are in the 'draft' state without a CVE ID + # should have one allocated by the PSF CVE Numbering Authority. + if state == "draft" and security_advisory.get("cve_id") is None: + cve_id = reserve_one_cve(cve_api) + patch_data["cve_id"] = cve_id + print(f" ✅ Will reserve CVE ID: {cve_id}") + + patch_data["collaborating_teams"] = [PSRT_GITHUB_TEAM_SLUG] + print(f" ➕ Will ensure team present: {PSRT_GITHUB_TEAM_SLUG}") + + # Apply updates, if any, to the security advisory. + if patch_data: + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data=patch_data, + ) + print(" 💾 Updated advisory") + else: + print(" â­ī¸ No updates needed") + + if advisory_count == 0: + print(" â„šī¸ No security advisories found") + + +def main() -> None: + print("Starting PSRT GitHub Security Advisory bot...") + gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() + github = GitHub( + AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key), + ) + cve_api = CveApi( + org="PSF", + username=os.environ["CVE_USERNAME"], + api_key=os.environ["CVE_API_KEY"], + env=os.environ.get("CVE_ENV", "prod"), + ) + + print("Fetching installations...") + # Apply to all repositories for each installation. + installations = github.rest.paginate( + github.rest.apps.list_installations, + ) + installation_count = 0 + for installation_data in installations: + installation_count += 1 + print(f"\nProcessing installation {installation_count}: {installation_data.account.login}") + + installation_github = github.with_auth( + github.auth.as_installation(installation_data.id), + ) + repos = installation_github.rest.paginate( + installation_github.rest.apps.list_repos_accessible_to_installation, + map_func=lambda r: r.parsed_data.repositories, + ) + for repo in repos: + print(f" Checking repo: {repo.owner.login}/{repo.name}") + apply_to_repo(installation_github, repo.owner.login, repo.name, cve_api) + + print(f"\nDone! Processed {installation_count} installation(s).") + + +if __name__ == "__main__": + main() diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..116bfcc --- /dev/null +++ b/test_app.py @@ -0,0 +1,161 @@ +import datetime +from unittest import mock + +import app +import pytest + + +@pytest.fixture +def year() -> str: + return str(datetime.date.today().year) + + +@pytest.fixture +def cve_id(year) -> str: + return f"CVE-{year}-0000" + + +@pytest.fixture +def cve_reserve_response(cve_id, year): + # See: https://github.com/CVEProject/cve-services/blob/dev/schemas/cve-id/create-cve-ids-response.json + return { + "meta": {"remaining_quota": 1000}, + "cve_ids": [ + { + "cve_id": cve_id, + "cve_year": year, + "owning_cna": "PSF", + "state": "RESERVED", + "requested_by": {"cna": "PSF", "user": "cna@python.org"}, + "requested": "2024-01-01T00:00:00Z", + }, + ], + } + + +def _create_advisory_dict(state, cve_id, collaborating_teams): + """Helper to create a security advisory dictionary.""" + return { + "ghsa_id": "GHSA-xxxx-xxxx-xxxx", + "state": state, + "cve_id": cve_id, + "collaborating_teams": [{"slug": team} for team in collaborating_teams], + } + + +@pytest.mark.parametrize("state", ["draft", "triage"]) +def test_adds_psrt_github_team_to_security_advisories(state) -> None: + security_advisory = _create_advisory_dict(state, "CVE-0000-0000", []) + + github = mock.Mock() + cve_api = mock.Mock() + + with mock.patch("app.get_repository_advisories") as get_repo_advs: + get_repo_advs.return_value = [security_advisory] + + app.apply_to_repo(github, "owner", "repo", cve_api) + + github.rest.security_advisories.update_repository_advisory.assert_called_once_with( + owner="owner", + repo="repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + data={"collaborating_teams": ["psrt"]}, + ) + + +@pytest.mark.parametrize("state", ["draft", "triage"]) +def test_appends_psrt_github_team_to_security_advisories(state) -> None: + security_advisory = _create_advisory_dict( + state, + "CVE-0000-0000", + ["python/other-team"], + ) + + github = mock.Mock() + cve_api = mock.Mock() + + with mock.patch("app.get_repository_advisories") as get_repo_advs: + get_repo_advs.return_value = [security_advisory] + + app.apply_to_repo(github, "owner", "repo", cve_api) + + github.rest.security_advisories.update_repository_advisory.assert_called_once_with( + owner="owner", + repo="repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + data={"collaborating_teams": ["psrt"]}, + ) + + +@pytest.mark.parametrize("state", ["closed", "published"]) +def test_does_not_modify_completed_security_advisories(state) -> None: + security_advisory = _create_advisory_dict(state, None, []) + + github = mock.Mock() + cve_api = mock.Mock() + + with mock.patch("app.get_repository_advisories") as get_repo_advs: + get_repo_advs.return_value = [security_advisory] + + app.apply_to_repo(github, "owner", "repo", cve_api) + + github.rest.security_advisories.update_repository_advisory.assert_not_called() + + +def test_reserves_cve_id_for_draft_security_advisories( + year, + cve_id, + cve_reserve_response, +) -> None: + security_advisory = _create_advisory_dict("draft", None, ["psrt"]) + + github = mock.Mock() + cve_api = mock.Mock() + cve_api.reserve.return_value = cve_reserve_response + + with mock.patch("app.get_repository_advisories") as get_repo_advs: + get_repo_advs.return_value = [security_advisory] + + app.apply_to_repo(github, "owner", "repo", cve_api) + + cve_api.reserve.assert_called_with(count=1, year=year, random=True) + github.rest.security_advisories.update_repository_advisory.assert_called_once_with( + owner="owner", + repo="repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + data={"cve_id": cve_id, "collaborating_teams": ["psrt"]}, + ) + + +@pytest.mark.parametrize("state", ["triage", "closed", "published"]) +def test_does_not_reserve_cve_id_for_triage_security_advisories(state) -> None: + security_advisory = _create_advisory_dict(state, None, ["psrt"]) + + github = mock.Mock() + cve_api = mock.Mock() + + with mock.patch("app.get_repository_advisories") as get_repo_advs: + get_repo_advs.return_value = [security_advisory] + + app.apply_to_repo(github, "owner", "repo", cve_api) + + cve_api.reserve.assert_not_called() + # Triage state should still add team + if state == "triage": + github.rest.security_advisories.update_repository_advisory.assert_called_once_with( + owner="owner", + repo="repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + data={"collaborating_teams": ["psrt"]}, + ) + else: + github.rest.security_advisories.update_repository_advisory.assert_not_called() + + +def test_reserve_one_cve_id(cve_reserve_response, cve_id, year) -> None: + cve_api = mock.Mock() + cve_api.reserve.return_value = cve_reserve_response + + assert app.reserve_one_cve(cve_api) == cve_id + + cve_api.reserve.assert_called_with(count=1, year=year, random=True) diff --git a/uv.lock b/uv.lock index 7a561a2..3b0ce8e 100644 --- a/uv.lock +++ b/uv.lock @@ -224,23 +224,6 @@ auth-app = [ { name = "pyjwt", extra = ["crypto"] }, ] -[[package]] -name = "greenlet" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -383,25 +366,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "playwright" -version = "1.55.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, - { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, - { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, - { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, - { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, - { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, - { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -414,11 +378,10 @@ wheels = [ [[package]] name = "psrt-ghsa-bot" version = "0.1.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "cvelib" }, { name = "githubkit", extra = ["auth-app"] }, - { name = "playwright" }, { name = "python-dotenv" }, ] @@ -426,8 +389,6 @@ dependencies = [ dev = [ { name = "mock" }, { name = "pytest" }, - { name = "pytest-playwright" }, - { name = "pytest-sugar" }, { name = "ruff" }, { name = "ty" }, ] @@ -436,7 +397,6 @@ dev = [ requires-dist = [ { name = "cvelib", specifier = ">=1.4.0" }, { name = "githubkit", extras = ["auth-app"], specifier = ">=0.13.5" }, - { name = "playwright", specifier = ">=1.55.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, ] @@ -444,8 +404,6 @@ requires-dist = [ dev = [ { name = "mock", specifier = ">=5.2.0" }, { name = "pytest", specifier = ">=8.4.2" }, - { name = "pytest-playwright", specifier = ">=0.7.1" }, - { name = "pytest-sugar", specifier = ">=1.1.1" }, { name = "ruff", specifier = ">=0.14.3" }, { name = "ty", specifier = ">=0.0.1a25" }, ] @@ -504,18 +462,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, ] -[[package]] -name = "pyee" -version = "13.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -555,47 +501,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] -[[package]] -name = "pytest-base-url" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, -] - -[[package]] -name = "pytest-playwright" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "playwright" }, - { name = "pytest" }, - { name = "pytest-base-url" }, - { name = "python-slugify" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a0/1e/9771990bad2b59d37728c4b6f28c234b3badbb2494bd72d54a6e2a988e23/pytest_playwright-0.7.1.tar.gz", hash = "sha256:94b551b2677ecdc16284fcd6a4f0045eafda47a60e74410f3fe4d8260e12cabf", size = 16769, upload-time = "2025-09-08T08:10:53.765Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/59/373da90ce6a1a46ca6a449bf16cea11a3c6e269814eb60e7668526350b95/pytest_playwright-0.7.1-py3-none-any.whl", hash = "sha256:fcc46510fb75f8eba6df3bc8e84e4e902483d92be98075f20b9d160651a36d90", size = 16754, upload-time = "2025-09-08T08:10:55.92Z" }, -] - -[[package]] -name = "pytest-sugar" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "termcolor" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.1" @@ -605,18 +510,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-slugify" -version = "8.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "text-unidecode" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -717,24 +610,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "termcolor" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/56/ab275c2b56a5e2342568838f0d5e3e66a32354adcc159b495e374cda43f5/termcolor-3.2.0.tar.gz", hash = "sha256:610e6456feec42c4bcd28934a8c87a06c3fa28b01561d46aa09a9881b8622c58", size = 14423, upload-time = "2025-10-25T19:11:42.586Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/d5/141f53d7c1eb2a80e6d3e9a390228c3222c27705cbe7f048d3623053f3ca/termcolor-3.2.0-py3-none-any.whl", hash = "sha256:a10343879eba4da819353c55cb8049b0933890c2ebf9ad5d3ecd2bb32ea96ea6", size = 7698, upload-time = "2025-10-25T19:11:41.536Z" }, -] - -[[package]] -name = "text-unidecode" -version = "1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, -] - [[package]] name = "ty" version = "0.0.1a25" From f7bcbe31e2d7ba1ec908959d59c83ab7b9ca3397 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:10:14 -0600 Subject: [PATCH 02/64] add playwright basic --- .env.example | 1 + app.py | 138 --------------- .../github_polyfills/__init__.py | 9 + test_app.py | 161 ------------------ tests/test_playwright_base.py | 104 +++++++++++ uv.lock | 127 +++++++++++++- 6 files changed, 240 insertions(+), 300 deletions(-) delete mode 100644 app.py create mode 100644 src/psrt_ghsa_bot/github_polyfills/__init__.py delete mode 100644 test_app.py create mode 100644 tests/test_playwright_base.py diff --git a/.env.example b/.env.example index a0ff71c..1e49e90 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ GH_CLIENT_ID="123456" GH_CLIENT_PRIVATE_KEY="base64...your...pem...keyfile" +GH_AUTH_TOKEN="ghp_123456" CVE_USERNAME="user@example.org" CVE_API_KEY="123456" CVE_ENV="testproddev" diff --git a/app.py b/app.py deleted file mode 100644 index 2fa08ce..0000000 --- a/app.py +++ /dev/null @@ -1,138 +0,0 @@ -"""GitHub application which applies the PSRT process for GitHub Security Advisories.""" - -import base64 -import datetime -import os -import typing - -from cvelib.cve_api import CveApi -from dotenv import load_dotenv -from githubkit import AppAuthStrategy, GitHub - -# Load environment variables from .env file -load_dotenv() - -if typing.TYPE_CHECKING: - pass - -PSRT_GITHUB_TEAM_SLUG = "psrt" - - -def get_repository_advisories( - github: GitHub, - owner: str, - repo: str, -) -> typing.Iterable[dict[str, typing.Any]]: - """Lists repository security advisories using the REST API.""" - from githubkit.exception import RequestFailed - import json - - try: - # Use direct request instead of paginate to avoid validation issues - response = github.rest.security_advisories.list_repository_advisories( - owner=owner, - repo=repo, - ) - # Parse JSON directly to bypass Pydantic validation - advisories = json.loads(response.content) - for advisory in advisories: - yield advisory - except RequestFailed as e: - # 404 means no advisories or no access - that's okay - if e.response.status_code == 404: - return - raise - - -def reserve_one_cve(cve_api: CveApi) -> str: - """Reserves a single CVE ID""" - resp = cve_api.reserve(count=1, random=True, year=str(datetime.date.today().year)) - cve_ids = [cve["cve_id"] for cve in resp["cve_ids"]] - assert len(cve_ids) == 1 - return cve_ids[0] - - -def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> None: - """Applies the PSRT GitHub Security Advisory process to the repository.""" - security_advisories = get_repository_advisories(github, owner, repo) - advisory_count = 0 - for security_advisory in security_advisories: - advisory_count += 1 - ghsa_id = security_advisory["ghsa_id"] - state = security_advisory["state"] - - # We only operate on in-progress security advisories. - if state not in ("triage", "draft"): - print(f" â­ī¸ Skipping {ghsa_id} (state: {state})") - continue - - print(f" 📋 Processing {ghsa_id} (state: {state})") - - # Maintain a dictionary of updates to make and then submit them all at once. - patch_data = {} - - # Advisories that are in the 'draft' state without a CVE ID - # should have one allocated by the PSF CVE Numbering Authority. - if state == "draft" and security_advisory.get("cve_id") is None: - cve_id = reserve_one_cve(cve_api) - patch_data["cve_id"] = cve_id - print(f" ✅ Will reserve CVE ID: {cve_id}") - - patch_data["collaborating_teams"] = [PSRT_GITHUB_TEAM_SLUG] - print(f" ➕ Will ensure team present: {PSRT_GITHUB_TEAM_SLUG}") - - # Apply updates, if any, to the security advisory. - if patch_data: - github.rest.security_advisories.update_repository_advisory( - owner=owner, - repo=repo, - ghsa_id=ghsa_id, - data=patch_data, - ) - print(" 💾 Updated advisory") - else: - print(" â­ī¸ No updates needed") - - if advisory_count == 0: - print(" â„šī¸ No security advisories found") - - -def main() -> None: - print("Starting PSRT GitHub Security Advisory bot...") - gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() - github = GitHub( - AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key), - ) - cve_api = CveApi( - org="PSF", - username=os.environ["CVE_USERNAME"], - api_key=os.environ["CVE_API_KEY"], - env=os.environ.get("CVE_ENV", "prod"), - ) - - print("Fetching installations...") - # Apply to all repositories for each installation. - installations = github.rest.paginate( - github.rest.apps.list_installations, - ) - installation_count = 0 - for installation_data in installations: - installation_count += 1 - print(f"\nProcessing installation {installation_count}: {installation_data.account.login}") - - installation_github = github.with_auth( - github.auth.as_installation(installation_data.id), - ) - repos = installation_github.rest.paginate( - installation_github.rest.apps.list_repos_accessible_to_installation, - map_func=lambda r: r.parsed_data.repositories, - ) - for repo in repos: - print(f" Checking repo: {repo.owner.login}/{repo.name}") - apply_to_repo(installation_github, repo.owner.login, repo.name, cve_api) - - print(f"\nDone! Processed {installation_count} installation(s).") - - -if __name__ == "__main__": - main() diff --git a/src/psrt_ghsa_bot/github_polyfills/__init__.py b/src/psrt_ghsa_bot/github_polyfills/__init__.py new file mode 100644 index 0000000..268619f --- /dev/null +++ b/src/psrt_ghsa_bot/github_polyfills/__init__.py @@ -0,0 +1,9 @@ +"""GitHub API polyfills using Playwright for UI automation. + +Things that we can't do with the GitHub API but need to do with Playwright +like commnet reading (commands like '@psrt-bot do the thing'), etc. +""" + +from .playwright_base import GitHubPlaywrightClient + +__all__ = ["GitHubPlaywrightClient"] diff --git a/test_app.py b/test_app.py deleted file mode 100644 index 116bfcc..0000000 --- a/test_app.py +++ /dev/null @@ -1,161 +0,0 @@ -import datetime -from unittest import mock - -import app -import pytest - - -@pytest.fixture -def year() -> str: - return str(datetime.date.today().year) - - -@pytest.fixture -def cve_id(year) -> str: - return f"CVE-{year}-0000" - - -@pytest.fixture -def cve_reserve_response(cve_id, year): - # See: https://github.com/CVEProject/cve-services/blob/dev/schemas/cve-id/create-cve-ids-response.json - return { - "meta": {"remaining_quota": 1000}, - "cve_ids": [ - { - "cve_id": cve_id, - "cve_year": year, - "owning_cna": "PSF", - "state": "RESERVED", - "requested_by": {"cna": "PSF", "user": "cna@python.org"}, - "requested": "2024-01-01T00:00:00Z", - }, - ], - } - - -def _create_advisory_dict(state, cve_id, collaborating_teams): - """Helper to create a security advisory dictionary.""" - return { - "ghsa_id": "GHSA-xxxx-xxxx-xxxx", - "state": state, - "cve_id": cve_id, - "collaborating_teams": [{"slug": team} for team in collaborating_teams], - } - - -@pytest.mark.parametrize("state", ["draft", "triage"]) -def test_adds_psrt_github_team_to_security_advisories(state) -> None: - security_advisory = _create_advisory_dict(state, "CVE-0000-0000", []) - - github = mock.Mock() - cve_api = mock.Mock() - - with mock.patch("app.get_repository_advisories") as get_repo_advs: - get_repo_advs.return_value = [security_advisory] - - app.apply_to_repo(github, "owner", "repo", cve_api) - - github.rest.security_advisories.update_repository_advisory.assert_called_once_with( - owner="owner", - repo="repo", - ghsa_id="GHSA-xxxx-xxxx-xxxx", - data={"collaborating_teams": ["psrt"]}, - ) - - -@pytest.mark.parametrize("state", ["draft", "triage"]) -def test_appends_psrt_github_team_to_security_advisories(state) -> None: - security_advisory = _create_advisory_dict( - state, - "CVE-0000-0000", - ["python/other-team"], - ) - - github = mock.Mock() - cve_api = mock.Mock() - - with mock.patch("app.get_repository_advisories") as get_repo_advs: - get_repo_advs.return_value = [security_advisory] - - app.apply_to_repo(github, "owner", "repo", cve_api) - - github.rest.security_advisories.update_repository_advisory.assert_called_once_with( - owner="owner", - repo="repo", - ghsa_id="GHSA-xxxx-xxxx-xxxx", - data={"collaborating_teams": ["psrt"]}, - ) - - -@pytest.mark.parametrize("state", ["closed", "published"]) -def test_does_not_modify_completed_security_advisories(state) -> None: - security_advisory = _create_advisory_dict(state, None, []) - - github = mock.Mock() - cve_api = mock.Mock() - - with mock.patch("app.get_repository_advisories") as get_repo_advs: - get_repo_advs.return_value = [security_advisory] - - app.apply_to_repo(github, "owner", "repo", cve_api) - - github.rest.security_advisories.update_repository_advisory.assert_not_called() - - -def test_reserves_cve_id_for_draft_security_advisories( - year, - cve_id, - cve_reserve_response, -) -> None: - security_advisory = _create_advisory_dict("draft", None, ["psrt"]) - - github = mock.Mock() - cve_api = mock.Mock() - cve_api.reserve.return_value = cve_reserve_response - - with mock.patch("app.get_repository_advisories") as get_repo_advs: - get_repo_advs.return_value = [security_advisory] - - app.apply_to_repo(github, "owner", "repo", cve_api) - - cve_api.reserve.assert_called_with(count=1, year=year, random=True) - github.rest.security_advisories.update_repository_advisory.assert_called_once_with( - owner="owner", - repo="repo", - ghsa_id="GHSA-xxxx-xxxx-xxxx", - data={"cve_id": cve_id, "collaborating_teams": ["psrt"]}, - ) - - -@pytest.mark.parametrize("state", ["triage", "closed", "published"]) -def test_does_not_reserve_cve_id_for_triage_security_advisories(state) -> None: - security_advisory = _create_advisory_dict(state, None, ["psrt"]) - - github = mock.Mock() - cve_api = mock.Mock() - - with mock.patch("app.get_repository_advisories") as get_repo_advs: - get_repo_advs.return_value = [security_advisory] - - app.apply_to_repo(github, "owner", "repo", cve_api) - - cve_api.reserve.assert_not_called() - # Triage state should still add team - if state == "triage": - github.rest.security_advisories.update_repository_advisory.assert_called_once_with( - owner="owner", - repo="repo", - ghsa_id="GHSA-xxxx-xxxx-xxxx", - data={"collaborating_teams": ["psrt"]}, - ) - else: - github.rest.security_advisories.update_repository_advisory.assert_not_called() - - -def test_reserve_one_cve_id(cve_reserve_response, cve_id, year) -> None: - cve_api = mock.Mock() - cve_api.reserve.return_value = cve_reserve_response - - assert app.reserve_one_cve(cve_api) == cve_id - - cve_api.reserve.assert_called_with(count=1, year=year, random=True) diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py new file mode 100644 index 0000000..dcaa574 --- /dev/null +++ b/tests/test_playwright_base.py @@ -0,0 +1,104 @@ +"""Tests for the Playwright base client.""" + +import os +from pathlib import Path + +import pytest + +from psrt_ghsa_bot.github_polyfills.playwright_base import GitHubPlaywrightClient + + +@pytest.fixture +def client() -> GitHubPlaywrightClient: + """Create a GitHubPlaywrightClient instance for testing.""" + return GitHubPlaywrightClient(headless=True) + + +def test_client_context_manager(): + """Test that the client works as a context manager.""" + with GitHubPlaywrightClient(headless=True) as client: + assert client.page is not None + assert client.context is not None + + +def test_client_start_and_close(client: GitHubPlaywrightClient): + """Test that the client can be started and closed manually.""" + client.start() + assert client.page is not None + assert client.context is not None + + client.close() + with pytest.raises(RuntimeError): + _ = client.page + + +def test_navigate_to_public_page(client: GitHubPlaywrightClient): + """Test navigation to a public GitHub page.""" + with client: + client.page.goto("https://github.com") + assert "github.com" in client.page.url + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing auth state (PAT tokens don't work for web UI auth)", +) +def test_authentication_with_saved_state(client: GitHubPlaywrightClient): + """Test authentication using saved state from manual login.""" + with client: + client.authenticate() + assert client._is_authenticated() + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient): + """Test navigation to a GHSA page (requires authentication).""" + with client: + client.authenticate() + + # ! TODO: will need to use different org/repo later? + client.navigate_to_ghsa("jolt-org", "ghsa-testing", "GHSA-f3x5-4pp6-r2mf") + + assert "GHSA-f3x5-4pp6-r2mf" in client.page.url + assert "security/advisories" in client.page.url + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_authentication_state_persistence(client: GitHubPlaywrightClient): + """Test that authentication state is saved and can be reused.""" + storage_state_path = Path("playwright/.auth/github_state.json") + + with client: + if storage_state_path.exists(): + assert client._is_authenticated() + + assert storage_state_path.exists() + + +def test_wait_for_page_ready(client: GitHubPlaywrightClient): + """Test the wait_for_page_ready helper.""" + with client: + client.page.goto("https://github.com") + client.wait_for_page_ready(timeout=10000) + + +@pytest.mark.skip(reason="Manual test - run explicitly with: pytest tests/test_playwright_base.py::test_manual_authentication -v") +def test_manual_authentication(): + """Test manual authentication flow. + + This test is marked as manual and should be run explicitly when needed + to set up initial authentication. +/ + Only really for localdev bcecause we want CI to be automagic ✨ so use PAT for that + + Run with: pytest tests/test_playwright_base.py::test_manual_authentication -v + """ + with GitHubPlaywrightClient(headless=False) as client: + client.authenticate_manual(timeout=120000) # 2 minutes + assert client._is_authenticated() diff --git a/uv.lock b/uv.lock index 3b0ce8e..7a561a2 100644 --- a/uv.lock +++ b/uv.lock @@ -224,6 +224,23 @@ auth-app = [ { name = "pyjwt", extra = ["crypto"] }, ] +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -366,6 +383,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "playwright" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, + { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, + { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, + { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, + { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, + { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -378,10 +414,11 @@ wheels = [ [[package]] name = "psrt-ghsa-bot" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "cvelib" }, { name = "githubkit", extra = ["auth-app"] }, + { name = "playwright" }, { name = "python-dotenv" }, ] @@ -389,6 +426,8 @@ dependencies = [ dev = [ { name = "mock" }, { name = "pytest" }, + { name = "pytest-playwright" }, + { name = "pytest-sugar" }, { name = "ruff" }, { name = "ty" }, ] @@ -397,6 +436,7 @@ dev = [ requires-dist = [ { name = "cvelib", specifier = ">=1.4.0" }, { name = "githubkit", extras = ["auth-app"], specifier = ">=0.13.5" }, + { name = "playwright", specifier = ">=1.55.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, ] @@ -404,6 +444,8 @@ requires-dist = [ dev = [ { name = "mock", specifier = ">=5.2.0" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-playwright", specifier = ">=0.7.1" }, + { name = "pytest-sugar", specifier = ">=1.1.1" }, { name = "ruff", specifier = ">=0.14.3" }, { name = "ty", specifier = ">=0.0.1a25" }, ] @@ -462,6 +504,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -501,6 +555,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + +[[package]] +name = "pytest-playwright" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/1e/9771990bad2b59d37728c4b6f28c234b3badbb2494bd72d54a6e2a988e23/pytest_playwright-0.7.1.tar.gz", hash = "sha256:94b551b2677ecdc16284fcd6a4f0045eafda47a60e74410f3fe4d8260e12cabf", size = 16769, upload-time = "2025-09-08T08:10:53.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/59/373da90ce6a1a46ca6a449bf16cea11a3c6e269814eb60e7668526350b95/pytest_playwright-0.7.1-py3-none-any.whl", hash = "sha256:fcc46510fb75f8eba6df3bc8e84e4e902483d92be98075f20b9d160651a36d90", size = 16754, upload-time = "2025-09-08T08:10:55.92Z" }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -510,6 +605,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -610,6 +717,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "termcolor" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/56/ab275c2b56a5e2342568838f0d5e3e66a32354adcc159b495e374cda43f5/termcolor-3.2.0.tar.gz", hash = "sha256:610e6456feec42c4bcd28934a8c87a06c3fa28b01561d46aa09a9881b8622c58", size = 14423, upload-time = "2025-10-25T19:11:42.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/d5/141f53d7c1eb2a80e6d3e9a390228c3222c27705cbe7f048d3623053f3ca/termcolor-3.2.0-py3-none-any.whl", hash = "sha256:a10343879eba4da819353c55cb8049b0933890c2ebf9ad5d3ecd2bb32ea96ea6", size = 7698, upload-time = "2025-10-25T19:11:41.536Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "ty" version = "0.0.1a25" From 7944e6bd009830aebb7d53defff535f1ddc1c261 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:10:24 -0600 Subject: [PATCH 03/64] remove stringent patch version --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index 12566ed..3767b4b 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14.0 \ No newline at end of file +3.14 \ No newline at end of file From 4dc9eae056e3a45ebde32927df362cba65927060 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 6 Nov 2025 15:15:47 -0600 Subject: [PATCH 04/64] playwright initial base + otp --- .env.example | 8 + .gitignore | 6 + pyproject.toml | 1 + .../github_polyfills/playwright_base.py | 292 ++++++++++++++++++ tests/test_playwright_base.py | 15 +- uv.lock | 11 + 6 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 src/psrt_ghsa_bot/github_polyfills/playwright_base.py diff --git a/.env.example b/.env.example index 1e49e90..cfaa486 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ +# GitHub App credentials (for API-capable operations) GH_CLIENT_ID="123456" GH_CLIENT_PRIVATE_KEY="base64...your...pem...keyfile" GH_AUTH_TOKEN="ghp_123456" + +# Playwright bot credentials (for browser automation) +GH_BOT_USERNAME=PSRT-GHSA-Automation +GH_BOT_PASSWORD= +GH_BOT_OTP_SECRET= # Key used to generate OTP codes + +# CVE API credentials CVE_USERNAME="user@example.org" CVE_API_KEY="123456" CVE_ENV="testproddev" diff --git a/.gitignore b/.gitignore index 9120a68..9f4ab66 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,9 @@ cython_debug/ playwright/.auth/ playwright-state/ **/playwright-state/ + +# Playwright videos and traces +playwright-videos/ +playwright-traces/ +*.webm +trace.zip diff --git a/pyproject.toml b/pyproject.toml index 7caa95c..e8b46d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "cvelib>=1.4.0", "githubkit[auth-app]>=0.13.5", "playwright>=1.55.0", + "pyotp>=2.9.0", "python-dotenv>=1.0.0", ] diff --git a/src/psrt_ghsa_bot/github_polyfills/playwright_base.py b/src/psrt_ghsa_bot/github_polyfills/playwright_base.py new file mode 100644 index 0000000..ebedc19 --- /dev/null +++ b/src/psrt_ghsa_bot/github_polyfills/playwright_base.py @@ -0,0 +1,292 @@ +"""Base Playwright client for GitHub UI automation. + +This module provides authentication and navigation utilities for automating +GitHub web UI interactions that are not available through the API. +""" + +import os +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from playwright.sync_api import Browser, BrowserContext, Page, Playwright, sync_playwright + +load_dotenv() + + +class GitHubPlaywrightClient: + """Client for automating GitHub UI interactions using Playwright. + + Contains helpers to quickly login, go to GHSA, and interact with the UI. + Also some debug options for localdev like slow_mo and record_video + and headless/nonheadless so you can see whats happening if something break + """ + + def __init__( + self, + headless: bool = True, + auth_token: str | None = None, + storage_state_path: str | None = None, + slow_mo: int = 0, + record_video: bool = False, + ) -> None: + """Initialize the GitHub Playwright client. + + Args: + headless: Whether to run browser in headless mode + auth_token: GitHub personal access token for authentication + storage_state_path: Path to saved authentication state file + slow_mo: Slow down operations by specified milliseconds (useful for debugging) + record_video: Whether to record videos of browser sessions + """ + self.headless = headless + self.auth_token = auth_token or os.getenv("GH_AUTH_TOKEN") + self.storage_state_path = storage_state_path or os.getenv( + "GH_AUTH_STATE_PATH", + "playwright/.auth/github_state.json", + ) + self.slow_mo = slow_mo + self.record_video = record_video + + self._playwright: Playwright | None = None + self._browser: Browser | None = None + self._context: BrowserContext | None = None + self._page: Page | None = None + + def __enter__(self) -> "GitHubPlaywrightClient": + """Context manager entry.""" + self.start() + return self + + def __exit__(self, *args: Any) -> None: + """Context manager exit.""" + self.close() + + def start(self) -> None: + """Start the Playwright browser session.""" + self._playwright = sync_playwright().start() + self._browser = self._playwright.chromium.launch( + headless=self.headless, + slow_mo=self.slow_mo, + ) + + storage_state_file = Path(self.storage_state_path) + storage_state = None + if storage_state_file.exists(): + storage_state = str(storage_state_file) + + context_options = {"storage_state": storage_state} + if self.record_video: + context_options["record_video_dir"] = "playwright-videos/" + + self._context = self._browser.new_context(**context_options) + self._page = self._context.new_page() + + def close(self) -> None: + """Close the browser and cleanup resources.""" + if self._page: + self._page.close() + self._page = None + if self._context: + self._context.close() + self._context = None + if self._browser: + self._browser.close() + self._browser = None + if self._playwright: + self._playwright.stop() + self._playwright = None + + @property + def page(self) -> Page: + """Get the current page instance.""" + if not self._page: + raise RuntimeError("Browser not started. Call start() first or use context manager.") + return self._page + + @property + def context(self) -> BrowserContext: + """Get the current browser context.""" + if not self._context: + raise RuntimeError("Browser not started. Call start() first or use context manager.") + return self._context + + def authenticate(self, force: bool = False) -> None: + """Authenticate to GitHub using storage state or credentials. + + Authentication methods tried in order: + 1. Existing storage state (saved session) - PREFERRED + 2. Automated login with GH_BOT_USERNAME/GH_BOT_PASSWORD + + # if user/pass/otp is not sufficient the only other options + # i think we could do is oauth maybe? GH_SESSION storing + # requires rotating it manually every 30 days or so... + # this was easiestf or now! + + Args: + force: Force re-authentication even if state exists + + Raises: + RuntimeError: If all authentication methods fail + """ + storage_state_file = Path(self.storage_state_path) + + # Check if we already have a valid session loaded from storage state + # else we need to login (below) + if storage_state_file.exists() and not force: + print("🔍 Checking saved authentication state...") + if self._is_authenticated(): + print("✅ Using saved authentication state") + return + else: + print("âš ī¸ Saved state is invalid or expired, re-authenticating...") + + username = os.getenv("GH_BOT_USERNAME") + password = os.getenv("GH_BOT_PASSWORD") + if username and password: + print(f"🔐 Logging in as {username}...") + self._login_with_credentials(username, password) + # Save the new session state + storage_state_file.parent.mkdir(parents=True, exist_ok=True) + self.context.storage_state(path=str(storage_state_file)) + print("✅ Login successful, state saved") + return + + raise RuntimeError( + "Authentication failed. Set GH_BOT_USERNAME and GH_BOT_PASSWORD environment variables, " + "or use authenticate_manual() for interactive login." + ) + + def authenticate_manual(self, timeout: int = 300_000) -> None: + """Perform manual authentication via GitHub's login page. + + This method navigates to GitHub's login page and waits for the user + to complete the login process manually. Once authenticated, the session + state is saved for future use. + + Args: + timeout: Maximum time to wait for manual login (in milliseconds) + """ + self.page.goto("https://github.com/login") + + print("\n" + "=" * 60) + print("MANUAL AUTHENTICATION REQUIRED") + print("=" * 60) + print("\nPlease log in to GitHub in the browser window.") + print("The session will be saved for future automated runs.") + print(f"\nWaiting up to {timeout / 1000} seconds...") + print("=" * 60 + "\n") + + # Wait for successful login by checking for redirect to main page + # or presence of user menu + try: + self.page.wait_for_url("https://github.com/**", timeout=timeout) + self.page.wait_for_selector("button[aria-label='Open user navigation menu']", timeout=10000) + except Exception as e: + raise RuntimeError(f"Manual authentication failed or timed out: {e}") + + storage_state_file = Path(self.storage_state_path) + storage_state_file.parent.mkdir(parents=True, exist_ok=True) + self.context.storage_state(path=str(storage_state_file)) + + print("\n✅ Authentication successful! Session saved.") + print(f" State saved to: {storage_state_file}\n") + + def _login_with_credentials(self, username: str, password: str) -> None: + """Perform automated login with username and password. + + This handles the standard GitHub login flow. 2FA must be pre-approved + or the storage state from setup_auth.py must be used. + + Args: + username: GitHub username + password: GitHub password + + Raises: + RuntimeError: If login fails or requires 2FA + """ + self.page.goto("https://github.com/login", wait_until="networkidle", timeout=30000) + self.page.wait_for_selector('input[name="login"]', state="visible", timeout=15000) + + self.page.locator('input[name="login"]').fill(username) + self.page.locator('input[name="password"]').fill(password) + self.page.locator('input[type="submit"][value="Sign in"]').click() + + self.page.wait_for_timeout(2000) + + if "sessions/two-factor" in self.page.url: + otp_secret = os.getenv("GH_BOT_OTP_SECRET") + if not otp_secret: + raise RuntimeError( + "2FA required but GH_BOT_OTP_SECRET not set. " + "Set environment variable or use authenticate_manual() for interactive login." + ) + + try: + import pyotp + + totp = pyotp.TOTP(otp_secret) + otp_code = totp.now() + print(" Using OTP code for 2FA...") + + self.page.locator('input[name="app_otp"]').fill(otp_code) + self.page.locator('button[type="submit"]:has-text("Verify")').click() + self.page.wait_for_timeout(3000) + + except ImportError: + raise RuntimeError("2FA required but pyotp not installed. did you 'uv sync' the project?") + + if not self._is_authenticated(): + raise RuntimeError("Login failed - authentication check failed") + + def _is_authenticated(self) -> bool: + """Check if the current session is authenticated. + + Checks cookies for dotcom_user or user_session indicators. + + Returns: + True if authenticated, False otherwise + """ + try: + self.page.goto("https://github.com", wait_until="domcontentloaded", timeout=15000) + + cookies = self.context.cookies() + auth_cookies = { + cookie["name"]: cookie for cookie in cookies if cookie["domain"] in [".github.com", "github.com"] + } + + has_user_session = "user_session" in auth_cookies + has_dotcom_user = "dotcom_user" in auth_cookies + + return has_user_session and has_dotcom_user + except Exception as e: + print(f" Debug: Auth check failed - {e}") + return False + + def navigate_to_ghsa(self, owner: str, repo: str, ghsa_id: str) -> None: + """Navigate to a specific GitHub Security Advisory page. + + Does some fanangling because networkidle never happens since + GH does polling for live updates to broadcas to everyone + subscribed to the ws. + + Args: + owner: Repository owner (organization or user) + repo: Repository name + ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + """ + url = f"https://github.com/{owner}/{repo}/security/advisories/{ghsa_id}" + self.page.goto(url, wait_until="domcontentloaded", timeout=30000) + + try: + self.page.wait_for_selector(".js-timeline-item, .TimelineItem", timeout=15000) + except Exception: + self.page.wait_for_selector("body", timeout=5000) + + def wait_for_page_ready(self, timeout: int = 30000) -> None: + """Wait for the page to be fully loaded and interactive. + + Args: + timeout: Maximum time to wait in milliseconds + """ + self.page.wait_for_load_state("networkidle", timeout=timeout) diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py index dcaa574..d5d0750 100644 --- a/tests/test_playwright_base.py +++ b/tests/test_playwright_base.py @@ -1,6 +1,5 @@ """Tests for the Playwright base client.""" -import os from pathlib import Path import pytest @@ -88,16 +87,18 @@ def test_wait_for_page_ready(client: GitHubPlaywrightClient): client.wait_for_page_ready(timeout=10000) -@pytest.mark.skip(reason="Manual test - run explicitly with: pytest tests/test_playwright_base.py::test_manual_authentication -v") +@pytest.mark.skip( + reason="Manual test - run explicitly with: pytest tests/test_playwright_base.py::test_manual_authentication -v" +) def test_manual_authentication(): """Test manual authentication flow. - This test is marked as manual and should be run explicitly when needed - to set up initial authentication. -/ - Only really for localdev bcecause we want CI to be automagic ✨ so use PAT for that + This test is marked as manual and should be run explicitly when needed + to set up initial authentication. + / + Only really for localdev bcecause we want CI to be automagic ✨ so use PAT for that - Run with: pytest tests/test_playwright_base.py::test_manual_authentication -v + Run with: pytest tests/test_playwright_base.py::test_manual_authentication -v """ with GitHubPlaywrightClient(headless=False) as client: client.authenticate_manual(timeout=120000) # 2 minutes diff --git a/uv.lock b/uv.lock index 7a561a2..a2cb295 100644 --- a/uv.lock +++ b/uv.lock @@ -419,6 +419,7 @@ dependencies = [ { name = "cvelib" }, { name = "githubkit", extra = ["auth-app"] }, { name = "playwright" }, + { name = "pyotp" }, { name = "python-dotenv" }, ] @@ -437,6 +438,7 @@ requires-dist = [ { name = "cvelib", specifier = ">=1.4.0" }, { name = "githubkit", extras = ["auth-app"], specifier = ">=0.13.5" }, { name = "playwright", specifier = ">=1.55.0" }, + { name = "pyotp", specifier = ">=2.9.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, ] @@ -539,6 +541,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyotp" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" }, +] + [[package]] name = "pytest" version = "8.4.2" From 92a4732f5b7aa9ef74efc83d19874aadd60e73a4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 6 Nov 2025 15:27:11 -0600 Subject: [PATCH 05/64] playwright comment grab --- scripts/demo_get_comments.py | 128 ++++++++ .../github_polyfills/__init__.py | 3 +- .../github_polyfills/get_comments.py | 291 ++++++++++++++++++ .../github_polyfills/playwright_base.py | 22 +- tests/test_get_comments.py | 201 ++++++++++++ 5 files changed, 635 insertions(+), 10 deletions(-) create mode 100644 scripts/demo_get_comments.py create mode 100644 src/psrt_ghsa_bot/github_polyfills/get_comments.py create mode 100644 tests/test_get_comments.py diff --git a/scripts/demo_get_comments.py b/scripts/demo_get_comments.py new file mode 100644 index 0000000..21d048d --- /dev/null +++ b/scripts/demo_get_comments.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +"""Demo script for reading GHSA comments using playwright to read comments. + +This demonstrates Issue #3 implementation: Reading comments from GitHub +Security Advisories that are not accessible via the GitHub API. + +Usage: + # Headless mode (default) + uv run python scripts/demo_get_comments.py + + # Debug mode with visible browser (for local development) + uv run python scripts/demo_get_comments.py --debug + +Requirements: + - Authenticated session (run with --debug for first time manual auth, + or set GH_BOT_USERNAME/GH_BOT_PASSWORD env vars) + + +Example: + ``` + ✗ uv run python scripts/demo_get_comments.py --debug + 🔍 Reading comments from jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf + + 🐛 Debug mode: Browser window visible, slow motion enabled + + 🔐 Authenticating... + 🔍 Checking saved authentication state... + ✅ Using saved authentication state + đŸ“Ĩ Fetching comments from GHSA... + + ✅ Found 1 comment(s): + + ================================================================================ + + [1] Comment by @JacobCoffee + ID: comment-0 + Created: 2025-11-05 20:03:21+00:00 + Updated: 2025-11-05 20:03:21+00:00 + Bot: False + Body: + + test + + -------------------------------------------------------------------------------- + + ✅ Demo complete! + ``` +""" + +import argparse + +from psrt_ghsa_bot.github_polyfills import GitHubPlaywrightClient, get_ghsa_comments + + +def main(): + """Demonstrate reading GHSA comments.""" + # Parse command line arguments + parser = argparse.ArgumentParser(description="Demo script for reading GHSA comments") + parser.add_argument( + "--debug", + action="store_true", + help="Run in debug mode with visible browser window (headless=False, slow_mo=500)", + ) + parser.add_argument( + "--owner", + default="jolt-org", + help="Repository owner (default: jolt-org)", + ) + parser.add_argument( + "--repo", + default="ghsa-testing", + help="Repository name (default: ghsa-testing)", + ) + parser.add_argument( + "--ghsa", + default="GHSA-f3x5-4pp6-r2mf", + help="GHSA ID (default: GHSA-f3x5-4pp6-r2mf)", + ) + args = parser.parse_args() + + owner = args.owner + repo = args.repo + ghsa_id = args.ghsa + + print(f"🔍 Reading comments from {owner}/{repo}/{ghsa_id}\n") + + # Configure client based on debug mode + # Create client and authenticate + if args.debug: + print("🐛 Debug mode: Browser window visible, slow motion enabled\n") + client = GitHubPlaywrightClient(headless=False, slow_mo=500) + else: + client = GitHubPlaywrightClient(headless=True) + + with client: + print("🔐 Authenticating...") + client.authenticate() + + # Get comments + print("đŸ“Ĩ Fetching comments from GHSA...\n") + comments = get_ghsa_comments(client, owner, repo, ghsa_id) + + # Display results + if not comments: + print("â„šī¸ No comments found on this GHSA.") + else: + print(f"✅ Found {len(comments)} comment(s):\n") + print("=" * 80) + + for i, comment in enumerate(comments, 1): + print(f"\n[{i}] Comment by @{comment.author}") + print(f" ID: {comment.id}") + print(f" Created: {comment.created_at}") + print(f" Updated: {comment.updated_at}") + print(f" Bot: {comment.is_bot_comment}") + print(" Body:\n") + + # Indent comment body + for line in comment.body.split("\n"): + print(f" {line}") + + print("\n" + "-" * 80) + + print("\n✅ Demo complete!") + + +if __name__ == "__main__": + main() diff --git a/src/psrt_ghsa_bot/github_polyfills/__init__.py b/src/psrt_ghsa_bot/github_polyfills/__init__.py index 268619f..0d75021 100644 --- a/src/psrt_ghsa_bot/github_polyfills/__init__.py +++ b/src/psrt_ghsa_bot/github_polyfills/__init__.py @@ -4,6 +4,7 @@ like commnet reading (commands like '@psrt-bot do the thing'), etc. """ +from .get_comments import GHSAComment, get_ghsa_comments from .playwright_base import GitHubPlaywrightClient -__all__ = ["GitHubPlaywrightClient"] +__all__ = ["GitHubPlaywrightClient", "GHSAComment", "get_ghsa_comments"] diff --git a/src/psrt_ghsa_bot/github_polyfills/get_comments.py b/src/psrt_ghsa_bot/github_polyfills/get_comments.py new file mode 100644 index 0000000..ba1c250 --- /dev/null +++ b/src/psrt_ghsa_bot/github_polyfills/get_comments.py @@ -0,0 +1,291 @@ +"""GitHub API polyfill for reading GHSA comments using Playwright. + +GitHub does not provide REST/GraphQL API endpoints for reading comments +on Security Advisories in draft/triage state. This module uses browser +automation to extract comment data from the GitHub web UI. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +from playwright.sync_api import Locator, TimeoutError as PlaywrightTimeoutError + +if TYPE_CHECKING: + from .playwright_base import GitHubPlaywrightClient + + +@dataclass +class GHSAComment: + """Represents a comment on a GitHub Security Advisory.""" + + id: str + """Unique identifier for the comment (extracted from DOM)""" + author: str + """GitHub username of the comment author""" + body: str + """Comment content/text""" + created_at: datetime + """Timestamp when comment was created""" + updated_at: datetime + """Timestamp when comment was last updated""" + is_bot_comment: bool + """True if comment is from psrt-ghsabot or other bot account""" + + def __repr__(self) -> str: + """for debugging.""" + return f"GHSAComment(id={self.id}, author={self.author}, created={self.created_at})" + + +def get_ghsa_comments( + client: "GitHubPlaywrightClient", + owner: str, + repo: str, + ghsa_id: str, +) -> list[GHSAComment]: + """Get all comments from a GitHub Security Advisory using Playwright. + + This function navigates to the GHSA page, extracts all comments from the + timeline, and handles pagination if needed. + + Args: + client: Authenticated GitHubPlaywrightClient instance + owner: Repository owner (organization or user) + repo: Repository name + ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + + Returns: + List of GHSAComment objects in chronological order + + Raises: + RuntimeError: If GHSA page cannot be loaded or comments cannot be parsed + PlaywrightTimeoutError: If page load times out + + Example: + >>> with GitHubPlaywrightClient() as client: + ... client.authenticate() + ... comments = get_ghsa_comments(client, "python", "cpython", "GHSA-1234-5678-9abc") + ... for comment in comments: + ... print(f"{comment.author}: {comment.body[:50]}") + """ + client.navigate_to_ghsa(owner, repo, ghsa_id) + + _load_all_comments(client) + comments = _extract_comments(client) + + return comments + + +def _load_all_comments(client: "GitHubPlaywrightClient") -> None: + """Load all comments by clicking 'Load more' buttons until exhausted. + + GitHub may paginate comments with "Show more..." / "Load more" buttons. + This function clicks them until all comments are visible. + + Args: + client: GitHubPlaywrightClient instance + """ + max_iterations = 50 # no loops allowee + iteration = 0 + + while iteration < max_iterations: + load_more_selectors = [ + "button:has-text('Show more')", + "button:has-text('Load more')", + "a:has-text('Show more')", + ".ajax-pagination-btn", + ] + + load_more_button = None + for selector in load_more_selectors: + try: + button = client.page.locator(selector).first + if button.is_visible(timeout=1000): + load_more_button = button + break + except PlaywrightTimeoutError: + continue + + if not load_more_button: + break + + try: + load_more_button.click() + client.page.wait_for_timeout(1000) + iteration += 1 + except Exception: + break + + +def _extract_comments(client: "GitHubPlaywrightClient") -> list[GHSAComment]: + """Extract all comment data from the current page. + + Uses multiple fallback selectors to handle GitHub UI changes. + + Args: + client: GitHubPlaywrightClient instance + + Returns: + List of parsed GHSAComment objects + """ + comments: list[GHSAComment] = [] + comment_selectors = [ + ".timeline-comment", + ".TimelineItem-body", + "[data-hpc]", + ".js-comment", + ] + + comment_elements: list[Locator] = [] + for selector in comment_selectors: + try: + elements = client.page.locator(selector).all() + if elements: + comment_elements = elements + break + except Exception: + continue + + if not comment_elements: + return comments + + for idx, element in enumerate(comment_elements): + try: + comment = _parse_comment_element(element, idx) + if comment: + comments.append(comment) + except Exception as e: + print(f"Warning: Failed to parse comment element {idx}: {e}") + continue + + return comments + + +# noinspection D +def _parse_comment_element(element: Locator, fallback_idx: int) -> GHSAComment | None: + """Parse a single comment element into a GHSAComment object. + + Args: + element: Playwright locator for the comment container + fallback_idx: Index to use if comment ID cannot be extracted + + Returns: + Parsed GHSAComment or None if parsing fails + """ + comment_id = None + for attr in ["data-comment-id", "id", "data-gid"]: + try: + comment_id = element.get_attribute(attr) + if comment_id: + break + except Exception: + continue + + if not comment_id: + comment_id = f"comment-{fallback_idx}" + + author = None + author_selectors = [ + ".author", + ".timeline-comment-header .author", + "[data-hovercard-type='user']", + ".comment-user", + "a.Link--primary", + ] + + for selector in author_selectors: + try: + author_element = element.locator(selector).first + author = author_element.text_content(timeout=1000) + if author: + author = author.strip() + break + except Exception: + continue + + if not author: + return None + + body = None + body_selectors = [ + ".comment-body", + ".markdown-body", + "[data-testid='comment-body']", + ".js-comment-body", + ] + + for selector in body_selectors: + try: + body_element = element.locator(selector).first + body = body_element.text_content(timeout=1000) + if body: + body = body.strip() + break + except Exception: + continue + + if not body: + body = "" # empty? comment body + + created_at = _extract_timestamp(element, "created") + updated_at = _extract_timestamp(element, "updated") or created_at + + is_bot = _is_bot_author(element, author) + + return GHSAComment( + id=comment_id, + author=author, + body=body, + created_at=created_at, + updated_at=updated_at, + is_bot_comment=is_bot, + ) + + +def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: + """Extract created or updated timestamp from comment element. + + Args: + element: Comment element locator + timestamp_type: "created" or "updated" + + Returns: + Parsed datetime or current time as fallback + """ + timestamp_selectors = [ + "relative-time", + "time[datetime]", + ".timestamp", + f"[title*='{timestamp_type}']", + ] + + for selector in timestamp_selectors: + try: + time_element = element.locator(selector).first + datetime_str = time_element.get_attribute("datetime", timeout=1000) + if datetime_str: + return datetime.fromisoformat(datetime_str.replace("Z", "+00:00")) + except Exception: + continue + + return datetime.now() + + +def _is_bot_author(element: Locator, author: str) -> bool: + """Determine if comment author is a bot account. + + Args: + element: Comment element locator + author: Author username + + Returns: + True if author is a bot + """ + if "bot" in author.lower(): + return True + + try: + bot_badge = element.locator(".Label:has-text('Bot')").first + return bot_badge.is_visible(timeout=500) + except Exception: + return False diff --git a/src/psrt_ghsa_bot/github_polyfills/playwright_base.py b/src/psrt_ghsa_bot/github_polyfills/playwright_base.py index ebedc19..656ac43 100644 --- a/src/psrt_ghsa_bot/github_polyfills/playwright_base.py +++ b/src/psrt_ghsa_bot/github_polyfills/playwright_base.py @@ -23,12 +23,12 @@ class GitHubPlaywrightClient: """ def __init__( - self, - headless: bool = True, - auth_token: str | None = None, - storage_state_path: str | None = None, - slow_mo: int = 0, - record_video: bool = False, + self, + headless: bool = True, + auth_token: str | None = None, + storage_state_path: str | None = None, + slow_mo: int = 0, + record_video: bool = False, ) -> None: """Initialize the GitHub Playwright client. @@ -71,11 +71,11 @@ def start(self) -> None: ) storage_state_file = Path(self.storage_state_path) - storage_state = None + storage_state: str | None = None if storage_state_file.exists(): storage_state = str(storage_state_file) - context_options = {"storage_state": storage_state} + context_options: dict[str, Any] = {"storage_state": storage_state} if self.record_video: context_options["record_video_dir"] = "playwright-videos/" @@ -286,7 +286,11 @@ def navigate_to_ghsa(self, owner: str, repo: str, ghsa_id: str) -> None: def wait_for_page_ready(self, timeout: int = 30000) -> None: """Wait for the page to be fully loaded and interactive. + Uses domcontentloaded instead of networkidle because GitHub pages + maintain WebSocket connections for live updates which prevent + networkidle from ever being reached. + Args: timeout: Maximum time to wait in milliseconds """ - self.page.wait_for_load_state("networkidle", timeout=timeout) + self.page.wait_for_load_state("domcontentloaded", timeout=timeout) diff --git a/tests/test_get_comments.py b/tests/test_get_comments.py new file mode 100644 index 0000000..09b4f92 --- /dev/null +++ b/tests/test_get_comments.py @@ -0,0 +1,201 @@ +"""Tests for GHSA comment reading functionality.""" + +from collections.abc import Generator +from datetime import datetime +from pathlib import Path + +import pytest + +from psrt_ghsa_bot.github_polyfills import GHSAComment, GitHubPlaywrightClient, get_ghsa_comments + + +@pytest.fixture +def authenticated_client() -> Generator[GitHubPlaywrightClient, None, None]: + """Create an authenticated GitHubPlaywrightClient instance for testing.""" + client = GitHubPlaywrightClient(headless=True) + client.start() + client.authenticate() + yield client + client.close() + + +@pytest.mark.skip(reason="idk how to test this actually") +def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient): + """Test basic comment retrieval from a GHSA.""" + # Use a test GHSA that we know has comments + get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + # + # # Verify we got a list (may be empty if no comments exist) + # assert isinstance(comments, list) + # + # # If there are comments, verify their structure + # for comment in comments: + # assert isinstance(comment, GHSAComment) + # assert isinstance(comment.id, str) + # assert isinstance(comment.author, str) + # assert isinstance(comment.body, str) + # assert isinstance(comment.created_at, datetime) + # assert isinstance(comment.updated_at, datetime) + # assert isinstance(comment.is_bot_comment, bool) + # + # # Basic validation + # assert len(comment.id) > 0 + # assert len(comment.author) > 0 + # # Body can be empty + # assert comment.created_at <= datetime.now() + # assert comment.updated_at <= datetime.now() + # assert comment.created_at <= comment.updated_at + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_ghsa_comment_dataclass_repr(): + """Test the GHSAComment repr method.""" + comment = GHSAComment( + id="123", + author="testuser", + body="Test comment", + created_at=datetime(2024, 1, 1, 12, 0, 0), + updated_at=datetime(2024, 1, 1, 12, 0, 0), + is_bot_comment=False, + ) + + repr_str = repr(comment) + assert "GHSAComment" in repr_str + assert "id=123" in repr_str + assert "author=testuser" in repr_str + assert "2024-01-01 12:00:00" in repr_str + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient): + """Test retrieval from a GHSA with no comments.""" + # Create or find a GHSA with zero comments + # For now, we'll just verify the function handles empty comment lists + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", # May or may not have comments + ) + + # Should return an empty list if no comments, not error + assert isinstance(comments, list) + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient): + """Test that bot comments are properly detected.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + # Check if any bot comments are detected + bot_comments = [c for c in comments if c.is_bot_comment] + [c for c in comments if not c.is_bot_comment] + + # Verify bot detection works (if any bot comments exist) + for bot_comment in bot_comments: + # Bot usernames typically contain "bot" + assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient): + """Test that comments are returned in chronological order.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + # If we have multiple comments, verify they're in chronological order + if len(comments) >= 2: + for i in range(len(comments) - 1): + assert comments[i].created_at <= comments[i + 1].created_at + + +def test_ghsa_comment_dataclass_fields(): + """Test that GHSAComment has all required fields.""" + comment = GHSAComment( + id="test-id-123", + author="octocat", + body="This is a test comment body", + created_at=datetime(2024, 1, 15, 10, 30, 0), + updated_at=datetime(2024, 1, 15, 11, 0, 0), + is_bot_comment=False, + ) + + assert comment.id == "test-id-123" + assert comment.author == "octocat" + assert comment.body == "This is a test comment body" + assert comment.created_at == datetime(2024, 1, 15, 10, 30, 0) + assert comment.updated_at == datetime(2024, 1, 15, 11, 0, 0) + assert comment.is_bot_comment is False + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_get_ghsa_comments_error_handling_invalid_ghsa(): + """Test error handling for invalid GHSA ID.""" + with GitHubPlaywrightClient(headless=True) as client: + client.authenticate() + + # Try to get comments from a non-existent GHSA + # This should either return empty list or raise a reasonable error + try: + comments = get_ghsa_comments( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-0000-0000-0000", # Invalid GHSA + ) + # If it doesn't error, should return empty list + assert isinstance(comments, list) + except Exception as e: + # If it does error, should be a reasonable error message + assert "GHSA" in str(e) or "404" in str(e) or "not found" in str(e).lower() + + +@pytest.mark.skip(reason="Integration test - requires specific test GHSA with known comment count") +def test_get_ghsa_comments_pagination(): + """Test pagination handling for GHSAs with many comments. + + This test should be run against a GHSA with enough comments to trigger + pagination (typically >20 comments). + """ + with GitHubPlaywrightClient(headless=True) as client: + client.authenticate() + + # TODO: Create or find a test GHSA with >20 comments + comments = get_ghsa_comments( + client, + owner="test-org", + repo="test-repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + ) + + # Verify we got all comments, not just the first page + assert len(comments) > 20 From a1e3a58be813400e70cb4eaff123685e6b69950d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 7 Nov 2025 11:17:45 -0600 Subject: [PATCH 06/64] add post comments --- .../polyfills/comments/__init__.py | 6 + .../polyfills/comments/post_comment.py | 145 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/psrt_ghsa_bot/polyfills/comments/__init__.py create mode 100644 src/psrt_ghsa_bot/polyfills/comments/post_comment.py diff --git a/src/psrt_ghsa_bot/polyfills/comments/__init__.py b/src/psrt_ghsa_bot/polyfills/comments/__init__.py new file mode 100644 index 0000000..bc4c18f --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/comments/__init__.py @@ -0,0 +1,6 @@ +"""GHSA comment operations using Playwright.""" + +from psrt_ghsa_bot.polyfills.get_comments import GHSAComment, get_ghsa_comments +from psrt_ghsa_bot.polyfills.post_comment import post_ghsa_comment + +__all__ = ["GHSAComment", "get_ghsa_comments", "post_ghsa_comment"] diff --git a/src/psrt_ghsa_bot/polyfills/comments/post_comment.py b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py new file mode 100644 index 0000000..6050208 --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py @@ -0,0 +1,145 @@ +"""For posting comments to GHSA using Playwright.""" + +from typing import TYPE_CHECKING + +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +if TYPE_CHECKING: + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + + +def post_ghsa_comment( + client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, + comment_body: str, +) -> str: + """Post a comment to a GitHub Security Advisory using Playwright. + + Navigates to the GHSA page, locates the comment form, + fills in the comment text, and submits it. + + Args: + client: Authenticated GitHubPlaywrightClient instance + owner: Repository owner (organization or user) + repo: Repository name + ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + comment_body: The text content of the comment to post + + Returns: + Comment ID of the newly created comment + + Raises: + RuntimeError: If comment cannot be posted or form not found + PlaywrightTimeoutError: If page load or form submission times out + ValueError: If comment_body is empty + + Example:i + >>> with GitHubPlaywrightClient() as client: + ... client.authenticate() + ... comment_id = post_ghsa_comment( + ... client, + ... "python", + ... "cpython", + ... "GHSA-1234-5678-9abc", + ... "This vulnerability has been verified." + ... ) + ... print(f"Posted comment: {comment_id}") + """ + if not comment_body or not comment_body.strip(): + raise ValueError("comment_body cannot be empty") + + client.navigate_to_ghsa(owner, repo, ghsa_id) + + _fill_comment_form(client, comment_body) + _submit_comment(client) + + comment_id = _wait_for_comment_posted(client, comment_body) + return comment_id + + +def _fill_comment_form(client: GitHubPlaywrightClient, comment_body: str) -> None: + """Locate and fill the comment textarea. + + Args: + client: GitHubPlaywrightClient instance + comment_body: Comment text to fill in + + Raises: + RuntimeError: If comment form cannot be found + """ + try: + textarea = client.page.locator('textarea[name="body"]').first + textarea.wait_for(state="visible", timeout=10000) + textarea.click() + textarea.fill(comment_body) + except PlaywrightTimeoutError: + raise RuntimeError( + "Could not find comment textarea. The page structure may have changed, " + "or you may not have permission to comment on this advisory." + ) + + +def _submit_comment(client: GitHubPlaywrightClient) -> None: + """Submit the comment form. + + Args: + client: GitHubPlaywrightClient instance + + Raises: + RuntimeError: If submit button cannot be found or clicked + """ + try: + submit_button = client.page.locator('button[type="submit"][name="comment"]').first + submit_button.wait_for(state="visible", timeout=5000) + submit_button.click() + except PlaywrightTimeoutError: + raise RuntimeError("Could not find comment submit button. The page structure may have changed.") + + +def _wait_for_comment_posted( + client: GitHubPlaywrightClient, + expected_body: str, + timeout: int = 15000, +) -> str: + """Wait for the comment to appear on the page after submission. + + Args: + client: GitHubPlaywrightClient instance + expected_body: The comment body we expect to see + timeout: Maximum time to wait in milliseconds + + Returns: + The ID of the newly posted comment + + Raises: + RuntimeError: If comment doesn't appear within timeout + """ + client.page.wait_for_timeout(2000) + + try: + # Find timeline item containing our comment text by checking text content + timeline_items = client.page.locator(".TimelineItem").all() + + for item in timeline_items: + try: + text_content = item.text_content(timeout=1000) + if text_content and expected_body.strip() in text_content: + comment_id = item.get_attribute("id") + if comment_id and comment_id.startswith("advisory-comment-"): + return comment_id.replace("advisory-comment-", "") + return "comment-posted" + except Exception: + continue + + raise RuntimeError( + f"Comment was submitted but could not be found on the page within {timeout}ms. " + "It may have been posted successfully but not yet visible." + ) + + except PlaywrightTimeoutError: + raise RuntimeError( + f"Comment was submitted but could not be found on the page within {timeout}ms. " + "It may have been posted successfully but not yet visible." + ) From d301cfaa28a5099159bdfd13b93c4b5cb8c0c8f0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 7 Nov 2025 11:18:10 -0600 Subject: [PATCH 07/64] organize things into module, merge comments tests --- scripts/demo_get_comments.py | 2 +- scripts/demo_post_comment.py | 101 +++++++++++ .../github_polyfills/__init__.py | 10 -- src/psrt_ghsa_bot/polyfills/__init__.py | 10 ++ .../comments}/get_comments.py | 2 +- .../playwright_base.py | 0 ...{test_get_comments.py => test_comments.py} | 170 +++++++++++++++++- tests/test_playwright_base.py | 2 +- 8 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 scripts/demo_post_comment.py delete mode 100644 src/psrt_ghsa_bot/github_polyfills/__init__.py create mode 100644 src/psrt_ghsa_bot/polyfills/__init__.py rename src/psrt_ghsa_bot/{github_polyfills => polyfills/comments}/get_comments.py (99%) rename src/psrt_ghsa_bot/{github_polyfills => polyfills}/playwright_base.py (100%) rename tests/{test_get_comments.py => test_comments.py} (56%) diff --git a/scripts/demo_get_comments.py b/scripts/demo_get_comments.py index 21d048d..15671c3 100644 --- a/scripts/demo_get_comments.py +++ b/scripts/demo_get_comments.py @@ -49,7 +49,7 @@ import argparse -from psrt_ghsa_bot.github_polyfills import GitHubPlaywrightClient, get_ghsa_comments +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient, get_ghsa_comments def main(): diff --git a/scripts/demo_post_comment.py b/scripts/demo_post_comment.py new file mode 100644 index 0000000..ef2de8b --- /dev/null +++ b/scripts/demo_post_comment.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Demo script for posting a comment to a GitHub Security Advisory. + +This script demonstrates how to use the post_ghsa_comment function to add +a comment to a GHSA using Playwright automation. + +Usage: + python scripts/demo_post_comment.py + python scripts/demo_post_comment.py --debug + python scripts/demo_post_comment.py --ghsa GHSA-xxxx-xxxx-xxxx --comment "My comment text" +""" + +import argparse +import sys +from datetime import datetime + +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient, post_ghsa_comment + + +def main() -> None: + """Run the demo.""" + parser = argparse.ArgumentParser(description="Post a comment to a GHSA") + parser.add_argument( + "--owner", + default="jolt-org", + help="Repository owner (default: jolt-org)", + ) + parser.add_argument( + "--repo", + default="ghsa-testing", + help="Repository name (default: ghsa-testing)", + ) + parser.add_argument( + "--ghsa", + default="GHSA-f3x5-4pp6-r2mf", + help="GHSA ID (default: GHSA-f3x5-4pp6-r2mf)", + ) + parser.add_argument( + "--comment", + default=None, + help="Comment text to post (default: auto-generated timestamp comment)", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Run in debug mode (visible browser, slow motion)", + ) + + args = parser.parse_args() + + owner = args.owner + repo = args.repo + ghsa_id = args.ghsa + + # Generate a default comment with timestamp if not provided + if args.comment: + comment_text = args.comment + else: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + comment_text = f"Test comment posted by demo script at {timestamp}" + + print(f"📝 Posting comment to {owner}/{repo}/{ghsa_id}\n") + print(f"Comment text: {comment_text}\n") + + if args.debug: + print("🐛 Debug mode: Browser window visible, slow motion enabled\n") + + # Initialize Playwright client + with GitHubPlaywrightClient( + headless=not args.debug, + slow_mo=1000 if args.debug else 0, + ) as client: + # Authenticate + print("🔐 Authenticating...") + try: + client.authenticate() + except RuntimeError as e: + print(f"\n❌ Authentication failed: {e}") + print("\nPlease ensure you have set up authentication:") + print(" 1. Set GH_BOT_USERNAME and GH_BOT_PASSWORD in .env") + print(" 2. Or run scripts/setup_auth.py for manual login") + sys.exit(1) + + # Post the comment + print("📤 Posting comment to GHSA...") + try: + comment_id = post_ghsa_comment(client, owner, repo, ghsa_id, comment_text) + + print("\n✅ Comment posted successfully!") + print(f" Comment ID: {comment_id}") + print(f" View at: https://github.com/{owner}/{repo}/security/advisories/{ghsa_id}") + + except Exception as e: + print(f"\n❌ Failed to post comment: {e}") + sys.exit(1) + + print("\n✅ Demo complete!") + + +if __name__ == "__main__": + main() diff --git a/src/psrt_ghsa_bot/github_polyfills/__init__.py b/src/psrt_ghsa_bot/github_polyfills/__init__.py deleted file mode 100644 index 0d75021..0000000 --- a/src/psrt_ghsa_bot/github_polyfills/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""GitHub API polyfills using Playwright for UI automation. - -Things that we can't do with the GitHub API but need to do with Playwright -like commnet reading (commands like '@psrt-bot do the thing'), etc. -""" - -from .get_comments import GHSAComment, get_ghsa_comments -from .playwright_base import GitHubPlaywrightClient - -__all__ = ["GitHubPlaywrightClient", "GHSAComment", "get_ghsa_comments"] diff --git a/src/psrt_ghsa_bot/polyfills/__init__.py b/src/psrt_ghsa_bot/polyfills/__init__.py new file mode 100644 index 0000000..140e06c --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/__init__.py @@ -0,0 +1,10 @@ +"""GitHub API polyfills using Playwright for UI automation. + +Things that we can't do with the GitHub API but need to do with Playwright +like comment reading/writing, etc. +""" + +from psrt_ghsa_bot.polyfills.comments import GHSAComment, get_ghsa_comments, post_ghsa_comment +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + +__all__ = ["GitHubPlaywrightClient", "GHSAComment", "get_ghsa_comments", "post_ghsa_comment"] diff --git a/src/psrt_ghsa_bot/github_polyfills/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py similarity index 99% rename from src/psrt_ghsa_bot/github_polyfills/get_comments.py rename to src/psrt_ghsa_bot/polyfills/comments/get_comments.py index ba1c250..90b5184 100644 --- a/src/psrt_ghsa_bot/github_polyfills/get_comments.py +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -12,7 +12,7 @@ from playwright.sync_api import Locator, TimeoutError as PlaywrightTimeoutError if TYPE_CHECKING: - from .playwright_base import GitHubPlaywrightClient + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient @dataclass diff --git a/src/psrt_ghsa_bot/github_polyfills/playwright_base.py b/src/psrt_ghsa_bot/polyfills/playwright_base.py similarity index 100% rename from src/psrt_ghsa_bot/github_polyfills/playwright_base.py rename to src/psrt_ghsa_bot/polyfills/playwright_base.py diff --git a/tests/test_get_comments.py b/tests/test_comments.py similarity index 56% rename from tests/test_get_comments.py rename to tests/test_comments.py index 09b4f92..0531ced 100644 --- a/tests/test_get_comments.py +++ b/tests/test_comments.py @@ -1,12 +1,19 @@ -"""Tests for GHSA comment reading functionality.""" +"""Tests for GHSA comment functionality (reading and writing).""" +import os from collections.abc import Generator from datetime import datetime from pathlib import Path import pytest +from githubkit import GitHub, TokenAuthStrategy -from psrt_ghsa_bot.github_polyfills import GHSAComment, GitHubPlaywrightClient, get_ghsa_comments +from psrt_ghsa_bot.polyfills import ( + GHSAComment, + GitHubPlaywrightClient, + get_ghsa_comments, + post_ghsa_comment, +) @pytest.fixture @@ -19,6 +26,54 @@ def authenticated_client() -> Generator[GitHubPlaywrightClient, None, None]: client.close() +@pytest.fixture +def test_ghsa() -> Generator[dict[str, str], None, None]: + """Create a test GHSA and clean it up after the test. + + Returns dict with: owner, repo, ghsa_id + """ + token = os.getenv("GH_AUTH_TOKEN") + if not token: + pytest.skip("GH_AUTH_TOKEN not set") + + github = GitHub(TokenAuthStrategy(token)) # type: ignore[arg-type] + owner = "jolt-org" + repo = "ghsa-testing" + + # Create a test advisory + response = github.rest.security_advisories.create_repository_advisory( + owner=owner, + repo=repo, + data={ + "summary": f"Test Advisory {datetime.now().timestamp()}", + "description": "This is a test advisory created by pytest. It will be deleted automatically.", + "severity": "low", + "vulnerabilities": [ + { + "package": {"ecosystem": "pip", "name": "test-package"}, + "vulnerable_version_range": "< 1.0.0", + } + ], + }, + ) + + ghsa_id = response.parsed_data.ghsa_id + + yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} + + # Cleanup: Close/delete the advisory + # Note: GitHub doesn't allow deleting advisories via API, but we can close them + try: + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"state": "closed"}, + ) + except Exception: + pass # Best effort cleanup + + @pytest.mark.skip(reason="idk how to test this actually") def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient): """Test basic comment retrieval from a GHSA.""" @@ -110,9 +165,7 @@ def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightC bot_comments = [c for c in comments if c.is_bot_comment] [c for c in comments if not c.is_bot_comment] - # Verify bot detection works (if any bot comments exist) for bot_comment in bot_comments: - # Bot usernames typically contain "bot" assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author @@ -199,3 +252,112 @@ def test_get_ghsa_comments_pagination(): # Verify we got all comments, not just the first page assert len(comments) > 20 + + +# ============================================================================ +# POST COMMENT TESTS +# ============================================================================ + + +@pytest.mark.skip(reason="Requires test GHSA creation (needs write permissions)") +def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, test_ghsa: dict[str, str]): + """Test basic comment posting to a GHSA.""" + test_comment = f"Test comment from pytest at {datetime.now().isoformat()}" + + comment_id = post_ghsa_comment( + authenticated_client, + owner=test_ghsa["owner"], + repo=test_ghsa["repo"], + ghsa_id=test_ghsa["ghsa_id"], + comment_body=test_comment, + ) + + assert isinstance(comment_id, str) + assert len(comment_id) > 0 + + +@pytest.mark.skipif( + not Path("playwright/.auth/github_state.json").exists(), + reason="Requires existing authentication state", +) +def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient): + """Test posting a comment and then reading it back.""" + unique_text = f"Roundtrip test {datetime.now().timestamp()}" + + # Post the comment + comment_id = post_ghsa_comment( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=unique_text, + ) + + assert comment_id is not None + + # Read comments back + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + # Find our comment + posted_comment = next((c for c in comments if unique_text in c.body), None) + assert posted_comment is not None + assert posted_comment.body == unique_text + + +def test_post_comment_empty_body_error(): + """Test that posting an empty comment raises ValueError.""" + with GitHubPlaywrightClient(headless=True) as client: + with pytest.raises(ValueError, match="comment_body cannot be empty"): + post_ghsa_comment( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body="", + ) + + +def test_post_comment_whitespace_only_error(): + """Test that posting whitespace-only comment raises ValueError.""" + with GitHubPlaywrightClient(headless=True) as client: + with pytest.raises(ValueError, match="comment_body cannot be empty"): + post_ghsa_comment( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=" \n\t ", + ) + + +@pytest.mark.skip(reason="Manual test - posts to real GHSA") +def test_post_comment_with_markdown(authenticated_client: GitHubPlaywrightClient): + """Test posting a comment with markdown formatting.""" + markdown_comment = f"""# Test Comment {datetime.now().timestamp()} + +This comment contains **bold**, *italic*, and `code`. + +- List item 1 +- List item 2 + +```python +def hello(): + return "world" +``` +""" + + comment_id = post_ghsa_comment( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=markdown_comment, + ) + + assert isinstance(comment_id, str) + assert len(comment_id) > 0 diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py index d5d0750..4e46c00 100644 --- a/tests/test_playwright_base.py +++ b/tests/test_playwright_base.py @@ -4,7 +4,7 @@ import pytest -from psrt_ghsa_bot.github_polyfills.playwright_base import GitHubPlaywrightClient +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient @pytest.fixture From b9882f701c070ce19e59c9ff311468256f62c0d1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:04:41 -0600 Subject: [PATCH 08/64] fix missing f --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e4daae3..b617566 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ lint: ## Lint the code fmt: ## Format the code @uv run ruff format . -mt-check: ## Runs Ruff format in check mode (no changes) +fmt-check: ## Runs Ruff format in check mode (no changes) @uv run --no-sync ruff format --check . type-check: ## Run type-checking From c928c2262576dbada14cabcb2c1cb30278b99e38 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:04:47 -0600 Subject: [PATCH 09/64] add helpers --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index b617566..03a37f8 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,10 @@ fmt-check: ## Runs Ruff format in check mode (no changes) type-check: ## Run type-checking @uv run ty check +ty: type-check ## Alias for type-check + +check: lint fmt type-check ## Run all checks except tests + test: ## Run tests @uv run pytest From 8061f44005c4f53c5d99daa9252542727127eee4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:05:08 -0600 Subject: [PATCH 10/64] add monitoring ci --- .env.example | 3 ++ .github/workflows/health-check.yml | 33 +++++++++++++ pyproject.toml | 3 ++ src/psrt_ghsa_bot/_monitoring.py | 78 ++++++++++++++++++++++++++++++ uv.lock | 15 ++++++ 5 files changed, 132 insertions(+) create mode 100644 .github/workflows/health-check.yml create mode 100644 src/psrt_ghsa_bot/_monitoring.py diff --git a/.env.example b/.env.example index cfaa486..438a498 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,6 @@ GH_BOT_OTP_SECRET= # Key used to generate OTP codes CVE_USERNAME="user@example.org" CVE_API_KEY="123456" CVE_ENV="testproddev" + +# Sentry +SENTRY_DSN= diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml new file mode 100644 index 0000000..fd0c9a1 --- /dev/null +++ b/.github/workflows/health-check.yml @@ -0,0 +1,33 @@ +name: "Health Check" + +on: + workflow_dispatch: + schedule: + - cron: "15 * * * *" + +jobs: + monitor: + runs-on: ubuntu-latest + name: "Monitor Workflow Health" + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install dependencies + run: uv sync --locked --no-editable --no-dev + + - name: Check workflow status and report to Sentry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: uv run python src/psrt_ghsa_bot/health_check.py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e8b46d0..3d98f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,12 @@ requires-python = ">=3.14.0" dependencies = [ "cvelib>=1.4.0", "githubkit[auth-app]>=0.13.5", + # we might could put this into a dep group to not load it every time we install + # inside gh actions for cron.yml or the sentry check in "playwright>=1.55.0", "pyotp>=2.9.0", "python-dotenv>=1.0.0", + "sentry-sdk>=2.22.0", ] [dependency-groups] diff --git a/src/psrt_ghsa_bot/_monitoring.py b/src/psrt_ghsa_bot/_monitoring.py new file mode 100644 index 0000000..ff643db --- /dev/null +++ b/src/psrt_ghsa_bot/_monitoring.py @@ -0,0 +1,78 @@ +"""Sentry cron monitoring integration for GH act workflows.""" + +import os +from typing import Literal + +import sentry_sdk + + +def init_sentry() -> None: + """Initialize Sentry SDK with DSN from envvars.""" + dsn = os.environ.get("SENTRY_DSN") + if not dsn: + print("âš ī¸ SENTRY_DSN not set, monitoring disabled") + return + + sentry_sdk.init( + dsn=dsn, + enable_tracing=False, + ) + + +def capture_checkin( + monitor_slug: str, + status: Literal["in_progress", "ok", "error"], + duration: float | None = None, +) -> str | None: + """Capture a Sentry cron check-in. + + Args: + monitor_slug: The unique identifier for this monitor (e.g., "psrt-ghsa-cron") + status: The status of the check-in ("in_progress", "ok", or "error") + duration: Optional duration in seconds for the job execution + + Returns: + Check-in ID if successful else none + """ + if not os.environ.get("SENTRY_DSN"): + return None + + try: + from sentry_sdk import crons + + check_in_id = crons.capture_checkin( + monitor_slug=monitor_slug, + status=status, + duration=duration, + ) + return check_in_id + except (ImportError, AttributeError): + print("âš ī¸ sentry_sdk.crons not available") + return None + + +def report_workflow_failure(workflow_name: str, run_id: str, conclusion: str) -> None: + """Report a workflow failure to Sentry as an error event. + + This is used by the health check workflow (health-chcek.yml) to report when other workflows fail. + We want to check status of our playright actions and our cron.yml action to make sure they are + running and since they arent running constantly on some web service anywhere this is the idea.. + + Args: + workflow_name: Name of the failed workflow + run_id: GitHub Actions run ID + conclusion: The conclusion status from GitHub Actions + """ + if not os.environ.get("SENTRY_DSN"): + return + + sentry_sdk.capture_message( + f"Workflow '{workflow_name}' failed with status: {conclusion}", + level="error", + extras={ + "workflow_name": workflow_name, + "run_id": run_id, + "conclusion": conclusion, + "workflow_url": f"https://github.com/{os.environ.get('GITHUB_REPOSITORY', 'unknown')}/actions/runs/{run_id}", + }, + ) diff --git a/uv.lock b/uv.lock index a2cb295..dc2ebdd 100644 --- a/uv.lock +++ b/uv.lock @@ -421,6 +421,7 @@ dependencies = [ { name = "playwright" }, { name = "pyotp" }, { name = "python-dotenv" }, + { name = "sentry-sdk" }, ] [package.dev-dependencies] @@ -440,6 +441,7 @@ requires-dist = [ { name = "playwright", specifier = ">=1.55.0" }, { name = "pyotp", specifier = ">=2.9.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "sentry-sdk", specifier = ">=2.22.0" }, ] [package.metadata.requires-dev] @@ -719,6 +721,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/89/1561b3dc8e28bf7978d031893297e89be266f53650c87bb14a29406a9791/sentry_sdk-2.45.0.tar.gz", hash = "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271", size = 373631, upload-time = "2025-11-18T13:23:22.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/c6/039121a0355bc1b5bcceef0dabf211b021fd435d0ee5c46393717bb1c09f/sentry_sdk-2.45.0-py2.py3-none-any.whl", hash = "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23", size = 404791, upload-time = "2025-11-18T13:23:20.533Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From 7fa9d3b059be315d92ee7da6492ef722ae9e0239 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:07:54 -0600 Subject: [PATCH 11/64] placeholder for playright things --- src/psrt_ghsa_bot/health_check.py | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/psrt_ghsa_bot/health_check.py diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py new file mode 100644 index 0000000..8640a4b --- /dev/null +++ b/src/psrt_ghsa_bot/health_check.py @@ -0,0 +1,100 @@ +"""Health check script for monitoring GitHub Actions workflows. + +TODO: i'd like to know exactly the error and which workflow is failing + and send it to sentry or slack or an email or whatever +""" + +import json +import subprocess +import sys + +from psrt_ghsa_bot._monitoring import capture_checkin, init_sentry, report_workflow_failure + + +def check_workflow_health() -> None: + """Check the health of configured workflows and report to Sentry.""" + init_sentry() + + capture_checkin("psrt-health-monitor", "in_progress") + + workflows_to_check = [ + {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": "psrt-ghsa-cron"}, + # {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, + ] + + all_healthy = True + + for workflow in workflows_to_check: + print(f"\nChecking workflow: {workflow['name']}") + + """ + This is like: + ➜ gh run list --workflow cron.yml --json conclusion,status,databaseId --limit 1 + [ + { + "conclusion": "success", + "databaseId": 19509767419, + "status": "completed" + } + ] + """ + + result = subprocess.run( + [ + "gh", + "run", + "list", + "--workflow", + workflow["file"], + "--json", + "conclusion,status,databaseId", + "--limit", + "5", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"âš ī¸ Failed to get workflow runs: {result.stderr}") + all_healthy = False + continue + + runs = json.loads(result.stdout) + if not runs: + print(f"âš ī¸ No runs found for {workflow['name']}") + continue + + completed_runs = [r for r in runs if r["status"] == "completed"] + if not completed_runs: + print(f"â„šī¸ No completed runs yet for {workflow['name']}") + continue + + latest_run = completed_runs[0] + conclusion = latest_run["conclusion"] + run_id = latest_run["databaseId"] + print(f" Latest run: {run_id} - {conclusion}") + + if conclusion in ["failure", "timed_out", "cancelled"]: + print(f"❌ Workflow failed with status: {conclusion}") + report_workflow_failure(workflow["name"], str(run_id), conclusion) + capture_checkin(workflow["monitor_slug"], "error") + all_healthy = False + elif conclusion == "success": + print("✅ Workflow succeeded") + capture_checkin(workflow["monitor_slug"], "ok") + else: + print(f"âš ī¸ Unexpected conclusion: {conclusion}") + all_healthy = False + + if all_healthy: + capture_checkin("psrt-health-monitor", "ok") + print("\n✅ All workflows healthy") + else: + capture_checkin("psrt-health-monitor", "error") + print("\n❌ Some workflows are unhealthy") + sys.exit(1) + + +if __name__ == "__main__": + check_workflow_health() From b9d114428e29b60f336cfb2bbf1859c03eb2c055 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:08:13 -0600 Subject: [PATCH 12/64] set username inside clien --- src/psrt_ghsa_bot/polyfills/playwright_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/psrt_ghsa_bot/polyfills/playwright_base.py b/src/psrt_ghsa_bot/polyfills/playwright_base.py index 656ac43..8cb0e28 100644 --- a/src/psrt_ghsa_bot/polyfills/playwright_base.py +++ b/src/psrt_ghsa_bot/polyfills/playwright_base.py @@ -47,6 +47,7 @@ def __init__( ) self.slow_mo = slow_mo self.record_video = record_video + self.username = os.environ["GH_BOT_USERNAME"] self._playwright: Playwright | None = None self._browser: Browser | None = None From 09b2a13f40ec761ff7ca5c5ca63b49e10159e1cb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:18:24 -0600 Subject: [PATCH 13/64] parse comments as commands --- src/psrt_ghsa_bot/commands/__init__.py | 14 + src/psrt_ghsa_bot/commands/authorization.py | 196 ++++++++++++ src/psrt_ghsa_bot/commands/executor.py | 311 ++++++++++++++++++++ src/psrt_ghsa_bot/commands/parser.py | 226 ++++++++++++++ src/psrt_ghsa_bot/comment_processor.py | 212 +++++++++++++ src/psrt_ghsa_bot/state.py | 198 +++++++++++++ 6 files changed, 1157 insertions(+) create mode 100644 src/psrt_ghsa_bot/commands/__init__.py create mode 100644 src/psrt_ghsa_bot/commands/authorization.py create mode 100644 src/psrt_ghsa_bot/commands/executor.py create mode 100644 src/psrt_ghsa_bot/commands/parser.py create mode 100644 src/psrt_ghsa_bot/comment_processor.py create mode 100644 src/psrt_ghsa_bot/state.py diff --git a/src/psrt_ghsa_bot/commands/__init__.py b/src/psrt_ghsa_bot/commands/__init__.py new file mode 100644 index 0000000..ef83000 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/__init__.py @@ -0,0 +1,14 @@ +"""Command processing system for PSRT GHSA Bot.""" + +from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized +from psrt_ghsa_bot.commands.executor import CommandResult, execute_command +from psrt_ghsa_bot.commands.parser import Command, parse_command + +__all__ = [ + "AuthorizationResult", + "Command", + "CommandResult", + "execute_command", + "is_authorized", + "parse_command", +] diff --git a/src/psrt_ghsa_bot/commands/authorization.py b/src/psrt_ghsa_bot/commands/authorization.py new file mode 100644 index 0000000..8f2d87a --- /dev/null +++ b/src/psrt_ghsa_bot/commands/authorization.py @@ -0,0 +1,196 @@ +"""Authorization checks for command execution. + +Determines if a user is authorized to execute bot commands based on: +- GHSA collaborator status (?) +- Repository admin status +- python/psrt team membership TODO: make this configurable, and allow list of org/teams? + like, what if we want PSRT to be able to responds across all PSF repos? (psf, python, pycon, pypi?) + or maybe we just say "team is $TEAM, and this bot works in $ORG as long as you are member of + that $TEAM" so we leave the user mgmt to the org admins. yeah.. probably that. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from githubkit import GitHub + + +@dataclass +class AuthorizationResult: + """Result of authorization check.""" + + authorized: bool + """Whether the user is authorized""" + reason: str + """Human-readable reason for the decision""" + + +def is_authorized( + github: GitHub, + username: str, + owner: str, + repo: str, + ghsa_id: str, +) -> AuthorizationResult: + """Check if user is authorized to execute commands. + + Authorization hierarchy: + 1. Members of python/psrt team + 2. GHSA collaborators + 3. Repository admins + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + AuthorizationResult indicating if user is authorized and a rason + """ + if _is_psrt_team_member(github, username): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is a member of python/psrt team", + ) + + if _is_ghsa_collaborator(github, username, owner, repo, ghsa_id): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is a collaborator on this advisory", + ) + + if _is_repo_admin(github, username, owner, repo): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is an admin of {owner}/{repo}", + ) + + return AuthorizationResult( + authorized=False, + reason=f"User {username} is not authorized to execute commands", + ) + + +def _is_psrt_team_member(github: GitHub, username: str) -> bool: + """Check if user is member of python/psrt team. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + + Returns: + True if user is a member of the python/psrt team + """ + try: + response = github.rest.teams.get_member_in_org( + org="python", + team_slug="psrt", + username=username, + ) + return response.status_code == 204 + except Exception: + return False + + +def _is_ghsa_collaborator( + github: GitHub, + username: str, + owner: str, + repo: str, + ghsa_id: str, +) -> bool: + """Check if user is collaborator on the GHSA. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + True if user is a collaborator on the advisory + """ + try: + advisory = github.rest.security_advisories.get_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + ) + + if not advisory.parsed_data: + return False + + collaborators = advisory.parsed_data.collaborating_users or [] + teams = advisory.parsed_data.collaborating_teams or [] + + for collaborator in collaborators: + if collaborator.login and collaborator.login.lower() == username.lower(): + return True + + return any(team.slug and _is_team_member(github, owner, team.slug, username) for team in teams) + except Exception: + return False + + +def _is_team_member( + github: GitHub, + org: str, + team_slug: str, + username: str, +) -> bool: + """Check if user is member of a specific team. + + Args: + github: Authenticated GitHub client + org: Organization name + team_slug: Team slug + username: GitHub username to check + + Returns: + True if user is a member of the team + """ + try: + response = github.rest.teams.get_member_in_org( + org=org, + team_slug=team_slug, + username=username, + ) + return response.status_code == 204 + except Exception: + return False + + +def _is_repo_admin( + github: GitHub, + username: str, + owner: str, + repo: str, +) -> bool: + """Check if user has admin permissions on repository. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + + Returns: + True if user is a repository admin + """ + try: + response = github.rest.repos.get_collaborator_permission_level( + owner=owner, + repo=repo, + username=username, + ) + + if not response.parsed_data or not response.parsed_data.permission: + return False + + return response.parsed_data.permission == "admin" + except Exception: + return False diff --git a/src/psrt_ghsa_bot/commands/executor.py b/src/psrt_ghsa_bot/commands/executor.py new file mode 100644 index 0000000..3fa7359 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/executor.py @@ -0,0 +1,311 @@ +"""Command execution engine for PSRT GHSA Bot. + +TODO: Maybe we should look into easily extensiblke commands + like how discord.py or others do it so it can autodiscover + cmds and register them and keep this file just for the + executor, auth, results, and parser... +""" + +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from cvelib.cve_api import CveApi + +from psrt_ghsa_bot.app import reserve_one_cve +from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized +from psrt_ghsa_bot.commands.parser import Command, get_help_text, get_unknown_command_response + +if TYPE_CHECKING: + from githubkit import GitHub + + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + + +@dataclass +class CommandResult: + """Result of command execution.""" + + success: bool + """Whether the command executed successfully""" + message: str + """Response message to post as comment""" + error: Exception | None = None + """Exception if command failed""" + + +def execute_command( + cmd: Command, + github: GitHub, + playwright_client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, +) -> CommandResult: + """Execute a parsed command like: + + "@ assign-cve" + + These are based on src/psrt_ghsa_bot/commands/parser.py:AVAILABLE_COMMANDS + and do an auth check before trying. + + Args: + cmd: Parsed command to execute + github: Authenticated GitHub API client + playwright_client: Playwright client for UI automation + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with success status and response message + """ + # TODO: i dont like this.. it will just grow and grow... + auth_result = is_authorized(github, cmd.author, owner, repo, ghsa_id) + + if not auth_result.authorized: + return _unauthorized_result(cmd, auth_result) + + if cmd.action == "help": + return _handle_help(playwright_client) + + if cmd.action == "status": + return _handle_status(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "reject": + return _handle_reject(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "assign-cve": + return _handle_assign_cve(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "publish": + return _handle_publish(cmd, github, owner, repo, ghsa_id) + + return CommandResult( + success=False, + message=get_unknown_command_response(cmd.action, playwright_client.username), + ) + + +def _unauthorized_result(cmd: Command, auth_result: AuthorizationResult) -> CommandResult: + """Generate result for unauthorized command attempt. + + Args: + cmd: The command that was attempted + auth_result: Authorization check result + + Returns: + CommandResult with unauthorized message + """ + message = ( + f"❌ **Unauthorized**\n\n" + f"@{cmd.author}, you are not authorized to execute bot commands.\n\n" + f"**Reason:** {auth_result.reason}\n\n" + f"Only members of the `python/psrt` team and advisory collaborators can use bot commands." + ) + + return CommandResult(success=False, message=message) + + +def _handle_help(playwright_client: GitHubPlaywrightClient) -> CommandResult: + """Handle help command. + + Args: + playwright_client: Playwright client (for bot username) + + Returns: + CommandResult with help text + """ + help_text = get_help_text(playwright_client.username) + return CommandResult(success=True, message=help_text) + + +def _handle_status(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle status command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with status information + """ + try: + advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + + state = advisory.parsed_data.state + cve_id = advisory.parsed_data.cve_id or "None assigned" + created_at = advisory.parsed_data.created_at + updated_at = advisory.parsed_data.updated_at + + from datetime import datetime + + created = datetime.fromisoformat(created_at) + days_old = (datetime.now(created.tzinfo) - created).days + + message = ( + f"📊 **Advisory Status**\n\n" + f"**Repository:** {owner}/{repo}\n" + f"**Advisory:** {ghsa_id}\n" + f"**State:** {state}\n" + f"**CVE ID:** {cve_id}\n" + f"**Created:** {days_old} days ago\n" + f"**Last Updated:** {updated_at}" + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"❌ **Error:** Failed to get advisory status: {e!s}", + error=e, + ) + + +def _handle_reject(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle CVE rejection command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with rejection confirmation + """ + if not cmd.arguments: + return CommandResult( + success=False, + message="❌ **Error:** Missing CVE ID\n\nUsage: `reject `\n\nExample: `reject CVE-2024-1234`", + ) + + cve_id = cmd.arguments[0] + + try: + advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + + current_cve = advisory.parsed_data.cve_id + + if current_cve is None: + return CommandResult( + success=False, + message=f"❌ **Error:** Advisory {ghsa_id} has no CVE ID assigned.", + ) + + if current_cve != cve_id: + return CommandResult( + success=False, + message=( + f"❌ **Error:** CVE ID mismatch\n\n" + f"Advisory {ghsa_id} is associated with {current_cve}, not {cve_id}." + ), + ) + + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"cve_id": None}, + ) + + message = ( + f"đŸšĢ **CVE Rejected**\n\n" + f"**Advisory:** {ghsa_id}\n" + f"**CVE ID:** {cve_id}\n\n" + f"The CVE ID has been removed from this advisory.\n\n" + f"_Note: This does not withdraw the CVE from the CVE system. " + f"CVE rejection via API requires additional CVE API integration._" + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"❌ **Error:** Failed to reject CVE: {e!s}", + error=e, + ) + + +def _handle_assign_cve(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle CVE assignment command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with assignment confirmation + """ + try: + advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + current_cve = advisory.parsed_data.cve_id + + if current_cve is not None: + return CommandResult( + success=False, + message=( + f"â„šī¸ **CVE Already Assigned**\n\n" + f"Advisory {ghsa_id} already has CVE ID {current_cve} assigned.\n\n" + f"Use `status` to view advisory details or `reject {current_cve}` to remove it." + ), + ) + + cve_api = CveApi( + org="PSF", + username=os.environ["CVE_USERNAME"], + api_key=os.environ["CVE_API_KEY"], + env=os.environ.get("CVE_ENV", "prod"), + ) + + cve_id = reserve_one_cve(cve_api) + + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"cve_id": cve_id}, + ) + + message = ( + f"🔖 **CVE Assigned**\n\n" + f"**Advisory:** {ghsa_id}\n" + f"**CVE ID:** {cve_id}\n\n" + f"A new CVE ID has been reserved and associated with this advisory." + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"❌ **Error:** Failed to assign CVE: {e!s}", + error=e, + ) + + +def _handle_publish(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle advisory publication command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with publication confirmation + """ + message = "đŸ“ĸ **Publish Advisory** (Stub)\n\nbut for now I am just a stub cmd :)" + + return CommandResult(success=True, message=message) diff --git a/src/psrt_ghsa_bot/commands/parser.py b/src/psrt_ghsa_bot/commands/parser.py new file mode 100644 index 0000000..52cd572 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/parser.py @@ -0,0 +1,226 @@ +"""Command parser for extracting bot commands from GHSA comments.""" + +import os +import re +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + + +class CommandInfo(TypedDict, total=False): + """Type definition for command metadata because type checke rhates me.""" + + description: str + usage: str + example: str + aliases: list[str] + + +@dataclass +class Command: + """Represents a parsed command from a GHSA comment.""" + + action: str + """The command action (e.g., 'help', 'reject', 'assign-cve')""" + arguments: list[str] + """List of arguments provided to the command""" + author: str + """GitHub username who issued the command""" + comment_id: str + """ID of the comment containing the command""" + timestamp: datetime + """When the command was issued""" + + def __repr__(self) -> str: + args_str = " ".join(self.arguments) if self.arguments else "(no args)" + return f"Command({self.action} {args_str} by {self.author})" + + +AVAILABLE_COMMANDS: dict[str, CommandInfo] = { + "help": { + "description": "Show this help message with all available commands", + "usage": "help", + "example": "help", + }, + "reject": { + "description": "Reject/withdraw a CVE ID for this advisory", + "usage": "reject ", + "example": "reject CVE-2024-1234", + "aliases": ["withdraw"], + }, + "assign-cve": { + "description": "Request CVE ID assignment for this advisory", + "usage": "assign-cve", + "example": "assign-cve", + "aliases": ["request-cve"], + }, + "status": { + "description": "Show current status of this advisory and associated CVE", + "usage": "status", + "example": "status", + }, + "publish": { + "description": "Publish this advisory and associated CVE ID", + "usage": "publish", + "example": "publish", + "aliases": ["release", "complete"], + }, +} + +COMMAND_ALIASES = { + "withdraw": "reject", + "request-cve": "assign-cve", + "release": "publish", + "complete": "publish", +} + + +def _build_command_pattern(bot_username: str) -> re.Pattern[str]: + """Build regex pattern for matching bot commands. + + Args: + bot_username: The bot's GitHub username.. gotten from env var. + + Returns: + Compiled regex pattern that matches @ [args] + """ + escaped_username = re.escape(bot_username) + return re.compile( + rf"@{escaped_username}\s+(\S+)(?:\s+(.+))?", + re.IGNORECASE | re.MULTILINE, + ) + + +def parse_command( + comment_body: str | None, + author: str, + comment_id: str, + bot_username: str, + timestamp: datetime | None = None, +) -> Command | None: + """Parse a command from a comment body. + + Looks for pattern: @ [arguments...] + + Args: + comment_body: The full text of the comment + author: GitHub username who wrote the comment + comment_id: Unique identifier for the comment + bot_username: GitHub username of the bot to look for + timestamp: When the comment was created (defaults to now) + + Returns: + Parsed Command object, or None if no valid command found + + Example: + >>> parse_command( + ... "@ reject CVE-2024-1234", + ... "JacobCoffee", + ... "comment-123", + ... "" + ... ) + Command(reject CVE-2024-1234 by JacobCoffee) + """ + if not comment_body: + return None + + pattern = _build_command_pattern(bot_username) + match = pattern.search(comment_body) + if not match: + return None + + action = match.group(1).lower() + action = COMMAND_ALIASES.get(action, action) + + arguments_str = match.group(2) + arguments = arguments_str.split() if arguments_str else [] + + if timestamp is None: + timestamp = datetime.now() + + return Command( + action=action, + arguments=arguments, + author=author, + comment_id=comment_id, + timestamp=timestamp, + ) + + +def is_valid_command(action: str) -> bool: + """Check if an action is a recognized command. + + Args: + action: The command action to validate + + Returns: + True if the action is recognized, False otherwise + """ + return action.lower() in AVAILABLE_COMMANDS + + +def get_help_text(bot_username: str | None = None) -> str: + """Generate help text listing all available commands. + + Args: + bot_username: The bot's GitHub username to use in examples. + Defaults to GH_BOT_USERNAME environment variable. + + Returns: + Formatted markdown help text + """ + lines = [ + "# PSRT GHSA Bot Commands", + "", + "Available commands:", + "", + ] + + for cmd_info in AVAILABLE_COMMANDS.values(): + usage = f"@{bot_username} {cmd_info['usage']}" + example = f"@{bot_username} {cmd_info['example']}" + + lines.append(f"### `{usage}`") + lines.append(cmd_info["description"]) + lines.append(f"**Example:** `{example}`") + + if "aliases" in cmd_info: + aliases = ", ".join(f"`{alias}`" for alias in cmd_info["aliases"]) + lines.append(f"**Aliases:** {aliases}") + + lines.append("") + + lines.extend( + [ + "--", + "", + f"_To use a command, mention `@{bot_username}` followed by the command name and any required arguments._", + "", + "_Only members of the `python/psrt` team and advisory collaborators can execute commands._", + ] + ) + + return "\n".join(lines) + + +def get_unknown_command_response(action: str, bot_username: str | None = None) -> str: + """Generate response message for unknown commands. + + Args: + action: The unrecognized command action + bot_username: The bot's GitHub username to use in help message. + Defaults to GH_BOT_USERNAME environment variable. + + Returns: + Formatted error message with help text + """ + if bot_username is None: + bot_username = os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") + + available = ", ".join(f"`{cmd}`" for cmd in AVAILABLE_COMMANDS) + + return ( + f"❌ Unknown command: `{action}`\n\n" + f"Available commands: {available}\n\n" + f"Use `@{bot_username} help` for detailed usage information." + ) diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py new file mode 100644 index 0000000..2d3bb9a --- /dev/null +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -0,0 +1,212 @@ +"""Comment processing service for PSRT GHSA Bot.""" + +import base64 +import contextlib +import os +from dataclasses import dataclass + +from dotenv import load_dotenv +from githubkit import AppAuthStrategy, GitHub + +from psrt_ghsa_bot.app import get_repository_advisories +from psrt_ghsa_bot.commands.executor import execute_command +from psrt_ghsa_bot.commands.parser import parse_command +from psrt_ghsa_bot.polyfills.comments import get_ghsa_comments, post_ghsa_comment +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +from psrt_ghsa_bot.state import StateManager + +load_dotenv() + + +@dataclass +class CommentProcessingStats: + """Statistics for a comment processing run.""" + + ghsas_checked: int = 0 + comments_found: int = 0 + commands_found: int = 0 + commands_executed: int = 0 + commands_skipped: int = 0 + errors: int = 0 + + +def process_ghsa_comments( + github: GitHub, + playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, + owner: str, + repo: str, + ghsa_id: str, +) -> int: + """Process comments for a single GHSA. + + Args: + github: Authenticated GitHub API client + playwright_client: Playwright client for UI automation + state_manager: State manager for tracking processed commands + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + Number of commands executed + """ + ghsa_key = f"{owner}/{repo}/{ghsa_id}" + + try: + comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id, debug=True) + except Exception as e: + print(f"\n âš ī¸ Could not fetch comments: {e}") + return 0 + + if not comments: + print("\n đŸ’Ŧ No comments found on this GHSA") + return 0 + + print(f"\n đŸ’Ŧ Found {len(comments)} comment(s)") + commands_executed = 0 + for comment in comments: + comment_id = comment.id + author = comment.author + body = comment.body + cmd = parse_command(body, author, comment_id, playwright_client.username, comment.created_at) + + if cmd is None: + body_preview = body[:50].replace('\n', ' ') if body else "(empty)" + print(f" ⏊ @{author}: no command | body: {body_preview}... | looking for: @{playwright_client.username}") + continue + + if state_manager.is_command_processed(ghsa_key, comment_id, body, author): + print(f" ⏊ Command from @{author} already processed: {cmd.action}") + continue + + print(f"\n đŸŽ¯ New command from @{author}: @{playwright_client.username} {cmd.action}") + try: + result = execute_command(cmd, github, playwright_client, owner, repo, ghsa_id) + post_ghsa_comment(playwright_client, owner, repo, ghsa_id, result.message) + state_manager.mark_command_processed(ghsa_key, comment_id, body, author) + commands_executed += 1 + print(f" ✅ Command executed successfully") + except Exception as e: + print(f" ❌ Command failed: {e}") + error_message = ( + f"❌ **Command Execution Failed**\n\n" + f"@{author}, an error occurred while processing your command:\n\n" + f"```\n{e!s}\n```\n\n" + f"Please contact the PSRT team if this error persists." + ) + + with contextlib.suppress(Exception): + post_ghsa_comment(playwright_client, owner, repo, ghsa_id, error_message) + + return commands_executed + + +def process_all_comments( + github: GitHub, + playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, +) -> CommentProcessingStats: + """Process comments for all GHSAs across all installations. + + Args: + github: Authenticated GitHub API client (app-level) + playwright_client: Playwright client for UI automation + state_manager: State manager for tracking processed commands + + Returns: + CommentProcessingStats with run statistics + """ + stats = CommentProcessingStats() + installations = github.rest.paginate(github.rest.apps.list_installations) + + for installation_data in installations: + print(f"\nđŸ“Ļ Installation: {installation_data.account.login}") + installation_github = github.with_auth(github.auth.as_installation(installation_data.id)) + repos = installation_github.rest.paginate( + installation_github.rest.apps.list_repos_accessible_to_installation, + map_func=lambda r: r.parsed_data.repositories, + ) + + for repo in repos: + owner = repo.owner.login + repo_name = repo.name + + try: + advisories = list(get_repository_advisories(installation_github, owner, repo_name)) + if not advisories: + continue + + count = len(advisories) + print(f" 📂 {owner}/{repo_name}: {count} {'advisory' if count == 1 else 'advisories'}") + + for advisory in advisories: + ghsa_id = advisory["ghsa_id"] + state_str = advisory["state"] + + if state_str not in ("triage", "draft"): + continue + + stats.ghsas_checked += 1 + print(f" 🔍 Checking {ghsa_id} ({state_str})...", end=" ") + try: + commands_executed = process_ghsa_comments( + installation_github, + playwright_client, + state_manager, + owner, + repo_name, + ghsa_id, + ) + if commands_executed > 0: + print(f"✅ {commands_executed} command(s) executed") + else: + print("â­ī¸ No new commands") + stats.commands_executed += commands_executed + except Exception as e: + print(f"❌ Error: {e}") + stats.errors += 1 + + except Exception as e: + print(f" âš ī¸ Error accessing {owner}/{repo_name}: {e}") + stats.errors += 1 + + return stats + + +def main() -> None: + """Cmment processing machine.""" + print("🤖 PSRT GHSA Bot - Comment Processor") + print("=" * 50) + + print("\n📡 Initializing GitHub API client...") + gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() + github = GitHub(AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key)) + + print("💾 Loading state manager...") + state_manager = StateManager() + state_manager.load() + + print("🌐 Starting Playwright browser...") + with GitHubPlaywrightClient() as playwright_client: + print("🔐 Authenticating to GitHub...") + playwright_client.authenticate() + print("✅ Authentication successful!\n") + + print("🔍 Processing comments across all installations...") + stats = process_all_comments(github, playwright_client, state_manager) + + print("\n" + "=" * 50) + print("📊 Processing Summary:") + print(f" GHSAs checked: {stats.ghsas_checked}") + print(f" Commands executed: {stats.commands_executed}") + print(f" Errors: {stats.errors}") + print("=" * 50) + + print("\n💾 Saving state...") + state_manager.save() + print("✅ Done!") + + +if __name__ == "__main__": + main() diff --git a/src/psrt_ghsa_bot/state.py b/src/psrt_ghsa_bot/state.py new file mode 100644 index 0000000..b937d37 --- /dev/null +++ b/src/psrt_ghsa_bot/state.py @@ -0,0 +1,198 @@ +"""State management for tracking processed comments and commands. + +We don't continuosly run the playwright process, we run it periodiclly in GHA. +So, we need a state tracking to keep track of the last time we ran the GHA +so we only process comments for GHSA things that have been created AT or +AFTER the last tiem we ran. + +We could do a simple file based thing touching an epoch a reading it +but this tries to rely on gha cache to make it a little faster. + +- we store the state in a file in the cache directory +- we use the cache directory to store the state file +- state file contains json obj with state including: + - date/time we last ran + - last comment id we processed + - set of commands we've processed + - count of commands processed +- we use the state file to determine what to process afterwards +- update stat efile AFTER processing this new set +- 🔁 +""" + +import hashlib +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + + +@dataclass +class GHSAState: + """State for a single GHSA.""" + + last_comment_id: str | None = None + last_processed_at: str | None = None + processed_commands: set[str] = field(default_factory=set) + commands_processed_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dict.""" + return { + "last_comment_id": self.last_comment_id, + "last_processed_at": self.last_processed_at, + "processed_commands": list(self.processed_commands), + "commands_processed_count": self.commands_processed_count, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> GHSAState: + """Creat from dict.""" + return cls( + last_comment_id=data.get("last_comment_id"), + last_processed_at=data.get("last_processed_at"), + processed_commands=set(data.get("processed_commands", [])), + commands_processed_count=data.get("commands_processed_count", 0), + ) + + +@dataclass +class BotState: + """Global bot state.""" + + last_run: str | None = None + ghsas: dict[str, GHSAState] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dict.""" + return { + "last_run": self.last_run, + "ghsas": {key: state.to_dict() for key, state in self.ghsas.items()}, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BotState: + """Create from dict.""" + return cls( + last_run=data.get("last_run"), + ghsas={key: GHSAState.from_dict(value) for key, value in data.get("ghsas", {}).items()}, + ) + + +class StateManager: + """Manages bot state with GHA cache + git storage.""" + + def __init__(self, state_file: Path | None = None) -> None: + """Initialize state manager. + + Args: + state_file: Path to state file. Defaults to repo root state.json + """ + self.state_file = state_file or Path(__file__).parent.parent.parent / "state.json" + self._state: BotState | None = None + + def load(self) -> BotState: + """Load state from file or create new.""" + if self._state is not None: + return self._state + + if self.state_file.exists(): + try: + with open(self.state_file) as f: + data = json.load(f) + self._state = BotState.from_dict(data) + except (json.JSONDecodeError, KeyError): + self._state = BotState() + else: + self._state = BotState() + + return self._state + + def save(self) -> None: + """Save state to file.""" + if self._state is None: + return + + self._state.last_run = datetime.now(UTC).isoformat() + + self.state_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.state_file, "w") as f: + json.dump(self._state.to_dict(), f, indent=2) + + def get_ghsa_state(self, ghsa_id: str) -> GHSAState: + """Get or create state for a GHSA. + + Args: + ghsa_id: GHSA identifier (e.g., "python/cpython/GHSA-xxxx-xxxx-xxxx") + + Returns: + GHSAState for the given GHSA + """ + state = self.load() + if ghsa_id not in state.ghsas: + state.ghsas[ghsa_id] = GHSAState() + return state.ghsas[ghsa_id] + + def is_command_processed(self, ghsa_id: str, comment_id: str, command_text: str, author: str) -> bool: + """Check if a command has been processed. + + TODO: so, if someone edits their comment will it change the hasH? + + Args: + ghsa_id: GHSA identifier + comment_id: GitHub comment ID + command_text: Raw command text + author: Comment author username + + Returns: + True if command was already processed + """ + ghsa_state = self.get_ghsa_state(ghsa_id) + command_hash = self._hash_command(comment_id, command_text, author) + return command_hash in ghsa_state.processed_commands + + def mark_command_processed(self, ghsa_id: str, comment_id: str, command_text: str, author: str) -> None: + """Mark a command as processed. + + Args: + ghsa_id: GHSA identifier + comment_id: GitHub comment ID + command_text: Raw command text + author: Comment author username + """ + ghsa_state = self.get_ghsa_state(ghsa_id) + command_hash = self._hash_command(comment_id, command_text, author) + ghsa_state.processed_commands.add(command_hash) + ghsa_state.commands_processed_count += 1 + ghsa_state.last_comment_id = comment_id + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + def _hash_command(self, comment_id: str, command_text: str, author: str) -> str: + """Generate unique hash for a command. + + Args: + comment_id: GitHub comment ID + command_text: Raw command text + author: Comment author username + + Returns: + SHA-256 hash of command components + """ + content = f"{comment_id}:{command_text}:{author}" + return hashlib.sha256(content.encode()).hexdigest()[:16] + + def update_ghsa_state(self, ghsa_id: str, last_comment_id: str | None = None) -> None: + """Update state for a GHSA after processing. + + Args: + ghsa_id: GHSA identifier + last_comment_id: Last processed comment ID + """ + ghsa_state = self.get_ghsa_state(ghsa_id) + if last_comment_id: + ghsa_state.last_comment_id = last_comment_id + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() From a629854ebd89801338f959ff04b2af8e38cb24d3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:18:32 -0600 Subject: [PATCH 14/64] idea file --- src/psrt_ghsa_bot/IDEAS.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/psrt_ghsa_bot/IDEAS.md diff --git a/src/psrt_ghsa_bot/IDEAS.md b/src/psrt_ghsa_bot/IDEAS.md new file mode 100644 index 0000000..33226ea --- /dev/null +++ b/src/psrt_ghsa_bot/IDEAS.md @@ -0,0 +1,5 @@ +- When someone @'s the bot, since they are a real user, can we use GH api + to find all mentions from their notification and act on that instead of scraping? + that would mean we wouldnt have to keep state tracking and processing things + "AFTER or ON" whenever the gh action ran las based on state +- \ No newline at end of file From 09210f51a17e34f4d536f2ada16b495606d09f52 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:12 -0600 Subject: [PATCH 15/64] run linting with rules actually enabled --- pyproject.toml | 37 +++++++++++++- src/psrt_ghsa_bot/_monitoring.py | 5 +- src/psrt_ghsa_bot/app.py | 26 +++------- src/psrt_ghsa_bot/polyfills/__init__.py | 2 +- .../polyfills/comments/__init__.py | 7 ++- .../polyfills/comments/get_comments.py | 39 ++++++++++----- .../polyfills/comments/post_comment.py | 18 ++++--- .../polyfills/playwright_base.py | 49 ++++++++----------- tests/test_comments.py | 35 +++++++------ tests/test_playwright_base.py | 16 +++--- 10 files changed, 133 insertions(+), 101 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3d98f10..8ca38da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,11 +36,46 @@ line-length = 120 indent-width = 4 [tool.ruff.lint] -ignore = ["D203", "D213", "COM812"] +select = ["ALL"] +ignore = ["D203", "D213", "COM812", "T201"] [tool.ruff.format] quote-style = "double" indent-style = "space" +[tool.ruff.lint.per-file-ignores] +"tests/**/*.*" = [ + "A", + "ARG", + "B", + "BLE", + "C901", + "D", + "DTZ", + "EM", + "FBT", + "G", + "N", + "PGH", + "PIE", + "PLR", + "PLW", + "PTH", + "RSE", + "S", + "S101", + "SIM", + "TC", + "TRY", + "SLF", + "ANN", + "FIX", + "TD" +] + [tool.ruff.lint.pydocstyle] convention = "google" + +[tool.pytest.ini_options] +anyio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" diff --git a/src/psrt_ghsa_bot/_monitoring.py b/src/psrt_ghsa_bot/_monitoring.py index ff643db..d6e0fe2 100644 --- a/src/psrt_ghsa_bot/_monitoring.py +++ b/src/psrt_ghsa_bot/_monitoring.py @@ -10,7 +10,6 @@ def init_sentry() -> None: """Initialize Sentry SDK with DSN from envvars.""" dsn = os.environ.get("SENTRY_DSN") if not dsn: - print("âš ī¸ SENTRY_DSN not set, monitoring disabled") return sentry_sdk.init( @@ -40,14 +39,12 @@ def capture_checkin( try: from sentry_sdk import crons - check_in_id = crons.capture_checkin( + return crons.capture_checkin( monitor_slug=monitor_slug, status=status, duration=duration, ) - return check_in_id except (ImportError, AttributeError): - print("âš ī¸ sentry_sdk.crons not available") return None diff --git a/src/psrt_ghsa_bot/app.py b/src/psrt_ghsa_bot/app.py index 9a8fec5..64b1e19 100644 --- a/src/psrt_ghsa_bot/app.py +++ b/src/psrt_ghsa_bot/app.py @@ -11,8 +11,6 @@ load_dotenv() -if typing.TYPE_CHECKING: - pass PSRT_GITHUB_TEAM_SLUG = "psrt" @@ -23,9 +21,10 @@ def get_repository_advisories( repo: str, ) -> typing.Iterable[dict[str, typing.Any]]: """Lists repository security advisories using the REST API.""" - from githubkit.exception import RequestFailed import json + from githubkit.exception import RequestFailed + try: # Use direct request instead of paginate to avoid validation issues response = github.rest.security_advisories.list_repository_advisories( @@ -34,8 +33,7 @@ def get_repository_advisories( ) # Parse JSON directly to bypass Pydantic validation advisories = json.loads(response.content) - for advisory in advisories: - yield advisory + yield from advisories except RequestFailed as e: # 404 means no advisories or no access - that's okay if e.response.status_code == 404: @@ -44,7 +42,7 @@ def get_repository_advisories( def reserve_one_cve(cve_api: CveApi) -> str: - """Reserves a single CVE ID""" + """Reserves a single CVE ID.""" resp = cve_api.reserve(count=1, random=True, year=str(datetime.date.today().year)) cve_ids = [cve["cve_id"] for cve in resp["cve_ids"]] assert len(cve_ids) == 1 @@ -62,11 +60,8 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non # We only operate on in-progress security advisories. if state not in ("triage", "draft"): - print(f" â­ī¸ Skipping {ghsa_id} (state: {state})") continue - print(f" 📋 Processing {ghsa_id} (state: {state})") - # Maintain a dictionary of updates to make and then submit them all at once. patch_data = {} @@ -75,10 +70,8 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non if state == "draft" and security_advisory.get("cve_id") is None: cve_id = reserve_one_cve(cve_api) patch_data["cve_id"] = cve_id - print(f" ✅ Will reserve CVE ID: {cve_id}") patch_data["collaborating_teams"] = [PSRT_GITHUB_TEAM_SLUG] - print(f" ➕ Will ensure team present: {PSRT_GITHUB_TEAM_SLUG}") # Apply updates, if any, to the security advisory. if patch_data: @@ -88,16 +81,14 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non ghsa_id=ghsa_id, data=patch_data, ) - print(" 💾 Updated advisory") else: - print(" â­ī¸ No updates needed") + pass if advisory_count == 0: - print(" â„šī¸ No security advisories found") + pass def main() -> None: - print("Starting PSRT GitHub Security Advisory bot...") gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() github = GitHub( AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key), @@ -109,7 +100,6 @@ def main() -> None: env=os.environ.get("CVE_ENV", "prod"), ) - print("Fetching installations...") # Apply to all repositories for each installation. installations = github.rest.paginate( github.rest.apps.list_installations, @@ -117,7 +107,6 @@ def main() -> None: installation_count = 0 for installation_data in installations: installation_count += 1 - print(f"\nProcessing installation {installation_count}: {installation_data.account.login}") installation_github = github.with_auth( github.auth.as_installation(installation_data.id), @@ -127,11 +116,8 @@ def main() -> None: map_func=lambda r: r.parsed_data.repositories, ) for repo in repos: - print(f" Checking repo: {repo.owner.login}/{repo.name}") apply_to_repo(installation_github, repo.owner.login, repo.name, cve_api) - print(f"\nDone! Processed {installation_count} installation(s).") - if __name__ == "__main__": main() diff --git a/src/psrt_ghsa_bot/polyfills/__init__.py b/src/psrt_ghsa_bot/polyfills/__init__.py index 140e06c..a005883 100644 --- a/src/psrt_ghsa_bot/polyfills/__init__.py +++ b/src/psrt_ghsa_bot/polyfills/__init__.py @@ -7,4 +7,4 @@ from psrt_ghsa_bot.polyfills.comments import GHSAComment, get_ghsa_comments, post_ghsa_comment from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient -__all__ = ["GitHubPlaywrightClient", "GHSAComment", "get_ghsa_comments", "post_ghsa_comment"] +__all__ = ["GHSAComment", "GitHubPlaywrightClient", "get_ghsa_comments", "post_ghsa_comment"] diff --git a/src/psrt_ghsa_bot/polyfills/comments/__init__.py b/src/psrt_ghsa_bot/polyfills/comments/__init__.py index bc4c18f..56a81a6 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/__init__.py +++ b/src/psrt_ghsa_bot/polyfills/comments/__init__.py @@ -1,6 +1,9 @@ """GHSA comment operations using Playwright.""" -from psrt_ghsa_bot.polyfills.get_comments import GHSAComment, get_ghsa_comments -from psrt_ghsa_bot.polyfills.post_comment import post_ghsa_comment +from psrt_ghsa_bot.polyfills.comments.get_comments import ( + GHSAComment, + get_ghsa_comments, +) +from psrt_ghsa_bot.polyfills.comments.post_comment import post_ghsa_comment __all__ = ["GHSAComment", "get_ghsa_comments", "post_ghsa_comment"] diff --git a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py index 90b5184..ca7ed80 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -9,7 +9,8 @@ from datetime import datetime from typing import TYPE_CHECKING -from playwright.sync_api import Locator, TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import Locator +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError if TYPE_CHECKING: from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient @@ -30,18 +31,18 @@ class GHSAComment: updated_at: datetime """Timestamp when comment was last updated""" is_bot_comment: bool - """True if comment is from psrt-ghsabot or other bot account""" + """True if comment is from or other bot account""" def __repr__(self) -> str: - """for debugging.""" + """For debugging.""" return f"GHSAComment(id={self.id}, author={self.author}, created={self.created_at})" def get_ghsa_comments( - client: "GitHubPlaywrightClient", + client: GitHubPlaywrightClient, owner: str, repo: str, - ghsa_id: str, + ghsa_id: str, debug: bool = False, ) -> list[GHSAComment]: """Get all comments from a GitHub Security Advisory using Playwright. @@ -53,6 +54,7 @@ def get_ghsa_comments( owner: Repository owner (organization or user) repo: Repository name ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + debug: Enable debug output Returns: List of GHSAComment objects in chronological order @@ -71,12 +73,10 @@ def get_ghsa_comments( client.navigate_to_ghsa(owner, repo, ghsa_id) _load_all_comments(client) - comments = _extract_comments(client) - - return comments + return _extract_comments(client, debug=debug) -def _load_all_comments(client: "GitHubPlaywrightClient") -> None: +def _load_all_comments(client: GitHubPlaywrightClient) -> None: """Load all comments by clicking 'Load more' buttons until exhausted. GitHub may paginate comments with "Show more..." / "Load more" buttons. @@ -117,13 +117,14 @@ def _load_all_comments(client: "GitHubPlaywrightClient") -> None: break -def _extract_comments(client: "GitHubPlaywrightClient") -> list[GHSAComment]: +def _extract_comments(client: GitHubPlaywrightClient, debug: bool = False) -> list[GHSAComment]: """Extract all comment data from the current page. Uses multiple fallback selectors to handle GitHub UI changes. Args: client: GitHubPlaywrightClient instance + debug: Enable debug output Returns: List of parsed GHSAComment objects @@ -140,22 +141,34 @@ def _extract_comments(client: "GitHubPlaywrightClient") -> list[GHSAComment]: for selector in comment_selectors: try: elements = client.page.locator(selector).all() + if debug and elements: + print(f"\n 🔍 DEBUG: Found {len(elements)} elements with selector '{selector}'") if elements: comment_elements = elements break - except Exception: + except Exception as e: + if debug: + print(f"\n 🔍 DEBUG: Selector '{selector}' failed: {e}") continue if not comment_elements: + if debug: + print("\n 🔍 DEBUG: No comment elements found with any selector") return comments + if debug: + print(f"\n 🔍 DEBUG: Parsing {len(comment_elements)} comment elements") + for idx, element in enumerate(comment_elements): try: comment = _parse_comment_element(element, idx) if comment: comments.append(comment) + if debug: + print(f"\n 🔍 DEBUG: Parsed comment from @{comment.author}: {comment.body[:30]}...") except Exception as e: - print(f"Warning: Failed to parse comment element {idx}: {e}") + if debug: + print(f"\n 🔍 DEBUG: Failed to parse element {idx}: {e}") continue return comments @@ -264,7 +277,7 @@ def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: time_element = element.locator(selector).first datetime_str = time_element.get_attribute("datetime", timeout=1000) if datetime_str: - return datetime.fromisoformat(datetime_str.replace("Z", "+00:00")) + return datetime.fromisoformat(datetime_str) except Exception: continue diff --git a/src/psrt_ghsa_bot/polyfills/comments/post_comment.py b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py index 6050208..863b580 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/post_comment.py +++ b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py @@ -48,15 +48,15 @@ def post_ghsa_comment( ... print(f"Posted comment: {comment_id}") """ if not comment_body or not comment_body.strip(): - raise ValueError("comment_body cannot be empty") + msg = "comment_body cannot be empty" + raise ValueError(msg) client.navigate_to_ghsa(owner, repo, ghsa_id) _fill_comment_form(client, comment_body) _submit_comment(client) - comment_id = _wait_for_comment_posted(client, comment_body) - return comment_id + return _wait_for_comment_posted(client, comment_body) def _fill_comment_form(client: GitHubPlaywrightClient, comment_body: str) -> None: @@ -75,10 +75,11 @@ def _fill_comment_form(client: GitHubPlaywrightClient, comment_body: str) -> Non textarea.click() textarea.fill(comment_body) except PlaywrightTimeoutError: - raise RuntimeError( + msg = ( "Could not find comment textarea. The page structure may have changed, " "or you may not have permission to comment on this advisory." ) + raise RuntimeError(msg) def _submit_comment(client: GitHubPlaywrightClient) -> None: @@ -95,7 +96,8 @@ def _submit_comment(client: GitHubPlaywrightClient) -> None: submit_button.wait_for(state="visible", timeout=5000) submit_button.click() except PlaywrightTimeoutError: - raise RuntimeError("Could not find comment submit button. The page structure may have changed.") + msg = "Could not find comment submit button. The page structure may have changed." + raise RuntimeError(msg) def _wait_for_comment_posted( @@ -133,13 +135,15 @@ def _wait_for_comment_posted( except Exception: continue - raise RuntimeError( + msg = ( f"Comment was submitted but could not be found on the page within {timeout}ms. " "It may have been posted successfully but not yet visible." ) + raise RuntimeError(msg) except PlaywrightTimeoutError: - raise RuntimeError( + msg = ( f"Comment was submitted but could not be found on the page within {timeout}ms. " "It may have been posted successfully but not yet visible." ) + raise RuntimeError(msg) diff --git a/src/psrt_ghsa_bot/polyfills/playwright_base.py b/src/psrt_ghsa_bot/polyfills/playwright_base.py index 8cb0e28..5f5d301 100644 --- a/src/psrt_ghsa_bot/polyfills/playwright_base.py +++ b/src/psrt_ghsa_bot/polyfills/playwright_base.py @@ -6,7 +6,7 @@ import os from pathlib import Path -from typing import Any +from typing import Any, Self from dotenv import load_dotenv from playwright.sync_api import Browser, BrowserContext, Page, Playwright, sync_playwright @@ -54,12 +54,12 @@ def __init__( self._context: BrowserContext | None = None self._page: Page | None = None - def __enter__(self) -> "GitHubPlaywrightClient": + def __enter__(self) -> Self: """Context manager entry.""" self.start() return self - def __exit__(self, *args: Any) -> None: + def __exit__(self, *args: object) -> None: """Context manager exit.""" self.close() @@ -102,14 +102,16 @@ def close(self) -> None: def page(self) -> Page: """Get the current page instance.""" if not self._page: - raise RuntimeError("Browser not started. Call start() first or use context manager.") + msg = "Browser not started. Call start() first or use context manager." + raise RuntimeError(msg) return self._page @property def context(self) -> BrowserContext: """Get the current browser context.""" if not self._context: - raise RuntimeError("Browser not started. Call start() first or use context manager.") + msg = "Browser not started. Call start() first or use context manager." + raise RuntimeError(msg) return self._context def authenticate(self, force: bool = False) -> None: @@ -134,29 +136,23 @@ def authenticate(self, force: bool = False) -> None: # Check if we already have a valid session loaded from storage state # else we need to login (below) - if storage_state_file.exists() and not force: - print("🔍 Checking saved authentication state...") - if self._is_authenticated(): - print("✅ Using saved authentication state") - return - else: - print("âš ī¸ Saved state is invalid or expired, re-authenticating...") + if storage_state_file.exists() and not force and self._is_authenticated(): + return username = os.getenv("GH_BOT_USERNAME") password = os.getenv("GH_BOT_PASSWORD") if username and password: - print(f"🔐 Logging in as {username}...") self._login_with_credentials(username, password) # Save the new session state storage_state_file.parent.mkdir(parents=True, exist_ok=True) self.context.storage_state(path=str(storage_state_file)) - print("✅ Login successful, state saved") return - raise RuntimeError( + msg = ( "Authentication failed. Set GH_BOT_USERNAME and GH_BOT_PASSWORD environment variables, " "or use authenticate_manual() for interactive login." ) + raise RuntimeError(msg) def authenticate_manual(self, timeout: int = 300_000) -> None: """Perform manual authentication via GitHub's login page. @@ -170,21 +166,14 @@ def authenticate_manual(self, timeout: int = 300_000) -> None: """ self.page.goto("https://github.com/login") - print("\n" + "=" * 60) - print("MANUAL AUTHENTICATION REQUIRED") - print("=" * 60) - print("\nPlease log in to GitHub in the browser window.") - print("The session will be saved for future automated runs.") - print(f"\nWaiting up to {timeout / 1000} seconds...") - print("=" * 60 + "\n") - # Wait for successful login by checking for redirect to main page # or presence of user menu try: self.page.wait_for_url("https://github.com/**", timeout=timeout) self.page.wait_for_selector("button[aria-label='Open user navigation menu']", timeout=10000) except Exception as e: - raise RuntimeError(f"Manual authentication failed or timed out: {e}") + msg = f"Manual authentication failed or timed out: {e}" + raise RuntimeError(msg) storage_state_file = Path(self.storage_state_path) storage_state_file.parent.mkdir(parents=True, exist_ok=True) @@ -218,10 +207,11 @@ def _login_with_credentials(self, username: str, password: str) -> None: if "sessions/two-factor" in self.page.url: otp_secret = os.getenv("GH_BOT_OTP_SECRET") if not otp_secret: - raise RuntimeError( + msg = ( "2FA required but GH_BOT_OTP_SECRET not set. " "Set environment variable or use authenticate_manual() for interactive login." ) + raise RuntimeError(msg) try: import pyotp @@ -235,10 +225,12 @@ def _login_with_credentials(self, username: str, password: str) -> None: self.page.wait_for_timeout(3000) except ImportError: - raise RuntimeError("2FA required but pyotp not installed. did you 'uv sync' the project?") + msg = "2FA required but pyotp not installed. did you 'uv sync' the project?" + raise RuntimeError(msg) if not self._is_authenticated(): - raise RuntimeError("Login failed - authentication check failed") + msg = "Login failed - authentication check failed" + raise RuntimeError(msg) def _is_authenticated(self) -> bool: """Check if the current session is authenticated. @@ -260,8 +252,7 @@ def _is_authenticated(self) -> bool: has_dotcom_user = "dotcom_user" in auth_cookies return has_user_session and has_dotcom_user - except Exception as e: - print(f" Debug: Auth check failed - {e}") + except Exception: return False def navigate_to_ghsa(self, owner: str, repo: str, ghsa_id: str) -> None: diff --git a/tests/test_comments.py b/tests/test_comments.py index 0531ced..8d22226 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,9 +1,9 @@ """Tests for GHSA comment functionality (reading and writing).""" import os -from collections.abc import Generator from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING import pytest from githubkit import GitHub, TokenAuthStrategy @@ -15,9 +15,12 @@ post_ghsa_comment, ) +if TYPE_CHECKING: + from collections.abc import Generator + @pytest.fixture -def authenticated_client() -> Generator[GitHubPlaywrightClient, None, None]: +def authenticated_client() -> Generator[GitHubPlaywrightClient]: """Create an authenticated GitHubPlaywrightClient instance for testing.""" client = GitHubPlaywrightClient(headless=True) client.start() @@ -27,7 +30,7 @@ def authenticated_client() -> Generator[GitHubPlaywrightClient, None, None]: @pytest.fixture -def test_ghsa() -> Generator[dict[str, str], None, None]: +def test_ghsa() -> Generator[dict[str, str]]: """Create a test GHSA and clean it up after the test. Returns dict with: owner, repo, ghsa_id @@ -75,7 +78,7 @@ def test_ghsa() -> Generator[dict[str, str], None, None]: @pytest.mark.skip(reason="idk how to test this actually") -def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient): +def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient) -> None: """Test basic comment retrieval from a GHSA.""" # Use a test GHSA that we know has comments get_ghsa_comments( @@ -111,7 +114,7 @@ def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient): not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_ghsa_comment_dataclass_repr(): +def test_ghsa_comment_dataclass_repr() -> None: """Test the GHSAComment repr method.""" comment = GHSAComment( id="123", @@ -133,7 +136,7 @@ def test_ghsa_comment_dataclass_repr(): not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient): +def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient) -> None: """Test retrieval from a GHSA with no comments.""" # Create or find a GHSA with zero comments # For now, we'll just verify the function handles empty comment lists @@ -152,7 +155,7 @@ def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrig not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient): +def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient) -> None: """Test that bot comments are properly detected.""" comments = get_ghsa_comments( authenticated_client, @@ -173,7 +176,7 @@ def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightC not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient): +def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient) -> None: """Test that comments are returned in chronological order.""" comments = get_ghsa_comments( authenticated_client, @@ -188,7 +191,7 @@ def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlayw assert comments[i].created_at <= comments[i + 1].created_at -def test_ghsa_comment_dataclass_fields(): +def test_ghsa_comment_dataclass_fields() -> None: """Test that GHSAComment has all required fields.""" comment = GHSAComment( id="test-id-123", @@ -211,7 +214,7 @@ def test_ghsa_comment_dataclass_fields(): not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_get_ghsa_comments_error_handling_invalid_ghsa(): +def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: """Test error handling for invalid GHSA ID.""" with GitHubPlaywrightClient(headless=True) as client: client.authenticate() @@ -233,7 +236,7 @@ def test_get_ghsa_comments_error_handling_invalid_ghsa(): @pytest.mark.skip(reason="Integration test - requires specific test GHSA with known comment count") -def test_get_ghsa_comments_pagination(): +def test_get_ghsa_comments_pagination() -> None: """Test pagination handling for GHSAs with many comments. This test should be run against a GHSA with enough comments to trigger @@ -260,7 +263,7 @@ def test_get_ghsa_comments_pagination(): @pytest.mark.skip(reason="Requires test GHSA creation (needs write permissions)") -def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, test_ghsa: dict[str, str]): +def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, test_ghsa: dict[str, str]) -> None: """Test basic comment posting to a GHSA.""" test_comment = f"Test comment from pytest at {datetime.now().isoformat()}" @@ -280,7 +283,7 @@ def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, t not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient): +def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient) -> None: """Test posting a comment and then reading it back.""" unique_text = f"Roundtrip test {datetime.now().timestamp()}" @@ -309,7 +312,7 @@ def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightC assert posted_comment.body == unique_text -def test_post_comment_empty_body_error(): +def test_post_comment_empty_body_error() -> None: """Test that posting an empty comment raises ValueError.""" with GitHubPlaywrightClient(headless=True) as client: with pytest.raises(ValueError, match="comment_body cannot be empty"): @@ -322,7 +325,7 @@ def test_post_comment_empty_body_error(): ) -def test_post_comment_whitespace_only_error(): +def test_post_comment_whitespace_only_error() -> None: """Test that posting whitespace-only comment raises ValueError.""" with GitHubPlaywrightClient(headless=True) as client: with pytest.raises(ValueError, match="comment_body cannot be empty"): @@ -336,7 +339,7 @@ def test_post_comment_whitespace_only_error(): @pytest.mark.skip(reason="Manual test - posts to real GHSA") -def test_post_comment_with_markdown(authenticated_client: GitHubPlaywrightClient): +def test_post_comment_with_markdown(authenticated_client: GitHubPlaywrightClient) -> None: """Test posting a comment with markdown formatting.""" markdown_comment = f"""# Test Comment {datetime.now().timestamp()} diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py index 4e46c00..2b6254c 100644 --- a/tests/test_playwright_base.py +++ b/tests/test_playwright_base.py @@ -13,14 +13,14 @@ def client() -> GitHubPlaywrightClient: return GitHubPlaywrightClient(headless=True) -def test_client_context_manager(): +def test_client_context_manager() -> None: """Test that the client works as a context manager.""" with GitHubPlaywrightClient(headless=True) as client: assert client.page is not None assert client.context is not None -def test_client_start_and_close(client: GitHubPlaywrightClient): +def test_client_start_and_close(client: GitHubPlaywrightClient) -> None: """Test that the client can be started and closed manually.""" client.start() assert client.page is not None @@ -31,7 +31,7 @@ def test_client_start_and_close(client: GitHubPlaywrightClient): _ = client.page -def test_navigate_to_public_page(client: GitHubPlaywrightClient): +def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: """Test navigation to a public GitHub page.""" with client: client.page.goto("https://github.com") @@ -42,7 +42,7 @@ def test_navigate_to_public_page(client: GitHubPlaywrightClient): not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing auth state (PAT tokens don't work for web UI auth)", ) -def test_authentication_with_saved_state(client: GitHubPlaywrightClient): +def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None: """Test authentication using saved state from manual login.""" with client: client.authenticate() @@ -53,7 +53,7 @@ def test_authentication_with_saved_state(client: GitHubPlaywrightClient): not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient): +def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: """Test navigation to a GHSA page (requires authentication).""" with client: client.authenticate() @@ -69,7 +69,7 @@ def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient): not Path("playwright/.auth/github_state.json").exists(), reason="Requires existing authentication state", ) -def test_authentication_state_persistence(client: GitHubPlaywrightClient): +def test_authentication_state_persistence(client: GitHubPlaywrightClient) -> None: """Test that authentication state is saved and can be reused.""" storage_state_path = Path("playwright/.auth/github_state.json") @@ -80,7 +80,7 @@ def test_authentication_state_persistence(client: GitHubPlaywrightClient): assert storage_state_path.exists() -def test_wait_for_page_ready(client: GitHubPlaywrightClient): +def test_wait_for_page_ready(client: GitHubPlaywrightClient) -> None: """Test the wait_for_page_ready helper.""" with client: client.page.goto("https://github.com") @@ -90,7 +90,7 @@ def test_wait_for_page_ready(client: GitHubPlaywrightClient): @pytest.mark.skip( reason="Manual test - run explicitly with: pytest tests/test_playwright_base.py::test_manual_authentication -v" ) -def test_manual_authentication(): +def test_manual_authentication() -> None: """Test manual authentication flow. This test is marked as manual and should be run explicitly when needed From 6a2f7946c9e2d059b946dbaf306d6a2a7c600d05 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:20 -0600 Subject: [PATCH 16/64] add some quick targets for local dev --- Makefile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 03a37f8..1db2d8a 100644 --- a/Makefile +++ b/Makefile @@ -30,4 +30,15 @@ test: ## Run tests ci: lint fmt type-check test ## Run everything app: ## Run the app - @uv run python app.py \ No newline at end of file + @uv run python app.py + +### --- Bot Things +### These all reequire .env file with the vars set based on .env.example! +cron-run: ## Run the cron bot (app.py) + @uv run python -m psrt_ghsa_bot.app + +playwright-run: ## Run playwright bot + @uv run python -m psrt_ghsa_bot.comment_processor + +health-check: ## Run health check + @uv run python -m psrt_ghsa_bot.health_check From 4534192db6cff0111cbeec82921b5351aaaa59fa Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:27 -0600 Subject: [PATCH 17/64] lint --- src/psrt_ghsa_bot/health_check.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index 8640a4b..fab3399 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -51,6 +51,7 @@ def check_workflow_health() -> None: "--limit", "5", ], + check=False, capture_output=True, text=True, ) From 2789d2883a574ae71e5bd0bf2be973916a5161f9 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:43 -0600 Subject: [PATCH 18/64] add gha --- .github/workflows/cron.yml | 2 +- .github/workflows/playwright.yml | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/playwright.yml diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index c218712..1aa6adc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -1,4 +1,4 @@ -name: "PSRT GHSA Bot" +name: "PSRT GHSA Cron Bot" on: workflow_dispatch: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..bdca2f2 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,69 @@ +name: "PSRT GHSA Playwright Bot" + +on: + workflow_dispatch: + schedule: + - cron: "*/5 * * * *" + +jobs: + process-comments: + runs-on: ubuntu-latest + name: "Process GHSA Comments" + steps: + - uses: actions/checkout@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install dependencies + run: uv sync --locked --no-editable --no-dev + + - name: Install Playwright browsers + run: uv run playwright install --with-deps chromium + + - name: Restore state from cache + id: cache-state + uses: actions/cache/restore@v4 + with: + path: state.json + key: playwright-state-${{ github.run_id }} + restore-keys: | + playwright-state- + + - name: Process comments + run: uv run python -m psrt_ghsa_bot.comment_processor + env: + GH_CLIENT_ID: ${{ vars.GH_CLIENT_ID }} + GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} + GH_CLIENT_PRIVATE_KEY: ${{ secrets.GH_CLIENT_PRIVATE_KEY }} + CVE_USERNAME: ${{ vars.CVE_USERNAME }} + CVE_API_KEY: ${{ secrets.CVE_API_KEY }} + CVE_ENV: ${{ vars.CVE_ENV }} + GH_BOT_USERNAME: ${{ vars.GH_BOT_USERNAME }} + GH_BOT_PASSWORD: ${{ secrets.GH_BOT_PASSWORD }} + GH_BOT_OTP_SECRET: ${{ secrets.GH_BOT_OTP_SECRET }} + + - name: Save state to cache + if: always() + uses: actions/cache/save@v4 + with: + path: state.json + key: playwright-state-${{ github.run_id }} + + - name: Commit state file + if: always() + run: | + git config user.name "PSRT-GHSA-Automation[bot]" + git config user.email "bot@python.org" + git add state.json + git diff --quiet || git commit -m "Update comment processing state [skip ci]" + git push || true \ No newline at end of file From 1cd9515cb96454954879508f7d6264e058f06f5d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:55 -0600 Subject: [PATCH 19/64] source state for exampl --- state.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 state.json diff --git a/state.json b/state.json new file mode 100644 index 0000000..d5b60cd --- /dev/null +++ b/state.json @@ -0,0 +1,15 @@ +{ + "last_run": "2025-11-19T21:17:09.904204+00:00", + "ghsas": { + "jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf": { + "last_comment_id": "comment-17", + "last_processed_at": "2025-11-19T21:12:48.590575+00:00", + "processed_commands": [ + "ed3d1e88521c7bc4", + "4d55106d50489f70", + "b47d14abe3060256" + ], + "commands_processed_count": 3 + } + } +} \ No newline at end of file From 80ccdd6683810b8d4cb7332ac6bb278b82c5b7c0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:58 -0600 Subject: [PATCH 20/64] lint --- scripts/demo_get_comments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/demo_get_comments.py b/scripts/demo_get_comments.py index 15671c3..c441227 100644 --- a/scripts/demo_get_comments.py +++ b/scripts/demo_get_comments.py @@ -52,7 +52,7 @@ from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient, get_ghsa_comments -def main(): +def main() -> None: """Demonstrate reading GHSA comments.""" # Parse command line arguments parser = argparse.ArgumentParser(description="Demo script for reading GHSA comments") From f0c33b03edc30006a3ac85f9fa27b770e332a820 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:20:06 -0600 Subject: [PATCH 21/64] placeholder file --- scripts/bootstrap_org.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 scripts/bootstrap_org.py diff --git a/scripts/bootstrap_org.py b/scripts/bootstrap_org.py new file mode 100644 index 0000000..ba2fb24 --- /dev/null +++ b/scripts/bootstrap_org.py @@ -0,0 +1,8 @@ +"""TODO: bootstrapping script for setting up a 'psrt' team if not existing. + +also other things tat might need to be done to get a test or ready for local dev +with the bot + +also maybe we should make the psrt team thing configurable because that doesnt +scale to other orgs that might want to use this bot +""" From 74631151f591c878ccfe099b96b6ad1eb5c1845b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:25:55 -0600 Subject: [PATCH 22/64] dont process bot comments --- src/psrt_ghsa_bot/comment_processor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py index 2d3bb9a..c6cca08 100644 --- a/src/psrt_ghsa_bot/comment_processor.py +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -69,6 +69,11 @@ def process_ghsa_comments( comment_id = comment.id author = comment.author body = comment.body + + if author == playwright_client.username: + # don't actually wanna process our own comments :) + continue + cmd = parse_command(body, author, comment_id, playwright_client.username, comment.created_at) if cmd is None: From 75119c9db466d2768d50f5f62ac33a3f7d511224 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 19:50:27 -0600 Subject: [PATCH 23/64] dont print but log --- src/psrt_ghsa_bot/logging_config.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/psrt_ghsa_bot/logging_config.py diff --git a/src/psrt_ghsa_bot/logging_config.py b/src/psrt_ghsa_bot/logging_config.py new file mode 100644 index 0000000..5896593 --- /dev/null +++ b/src/psrt_ghsa_bot/logging_config.py @@ -0,0 +1,24 @@ +"""Centralized logging configuration for PSRT GHSA Bot.""" + +import logging +import sys + + +def setup_logging(level: int = logging.INFO) -> None: + """Configure logging for entire app. + + Args: + level: Logging level (default: INFO) + """ + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler("psrt-ghsa-bot.log"), + logging.StreamHandler(sys.stdout), + ], + ) + + # set these higher so they arent noiys.. + logging.getLogger("playwright").setLevel(logging.WARNING) + logging.getLogger("githubkit").setLevel(logging.WARNING) From cc11440991544d02fe90f609ec1d856d9aca0664 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 20:18:58 -0600 Subject: [PATCH 24/64] swap to log instead of print, run lint/fmt --- .gitignore | 1 + pyproject.toml | 25 +- scripts/__init__.py | 1 + src/psrt_ghsa_bot/__init__.py | 4 + src/psrt_ghsa_bot/_monitoring.py | 7 +- src/psrt_ghsa_bot/app.py | 24 +- src/psrt_ghsa_bot/commands/authorization.py | 12 +- src/psrt_ghsa_bot/commands/executor.py | 12 +- src/psrt_ghsa_bot/commands/parser.py | 5 +- src/psrt_ghsa_bot/comment_processor.py | 105 ++++---- src/psrt_ghsa_bot/health_check.py | 43 ++- .../polyfills/comments/get_comments.py | 53 ++-- .../polyfills/comments/post_comment.py | 21 +- .../polyfills/playwright_base.py | 27 +- src/psrt_ghsa_bot/state.py | 4 +- src/psrt_ghsa_bot/utils/__init__.py | 1 + src/psrt_ghsa_bot/utils/bot_helpers.py | 21 ++ state.json | 4 +- tests/test_comments.py | 17 +- uv.lock | 247 +++++++++--------- 20 files changed, 353 insertions(+), 281 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 src/psrt_ghsa_bot/utils/__init__.py create mode 100644 src/psrt_ghsa_bot/utils/bot_helpers.py diff --git a/.gitignore b/.gitignore index 9f4ab66..8be575a 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ playwright-videos/ playwright-traces/ *.webm trace.zip +debug_*.png \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8ca38da..d38b928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,19 @@ indent-width = 4 [tool.ruff.lint] select = ["ALL"] -ignore = ["D203", "D213", "COM812", "T201"] +ignore = [ + "D203", + "D213", + "COM812", + "T201", + "TD", # todo without author + "FIX002", # Line contains TODO + "PLR0913", # Too many arguments + "PLR0911", # Too many returns + "C901", # Too complex + "ARG001", # unused arg + "BLE001", # blind except +] [tool.ruff.format] quote-style = "double" @@ -70,7 +82,16 @@ indent-style = "space" "SLF", "ANN", "FIX", - "TD" + "TD", + "ERA", +] +"scripts/**/*.*" = [ + "INP001", # Implicit namespace package + "EXE001", # Shebang not executable + "RUF001", # Ambiguous unicode + "DTZ", # Timezone issues + "S", # Security issues + "T201", # Print statements ] [tool.ruff.lint.pydocstyle] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..ca6e887 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts for PSRT GHSA bot.""" diff --git a/src/psrt_ghsa_bot/__init__.py b/src/psrt_ghsa_bot/__init__.py index a781b92..94fcdcb 100644 --- a/src/psrt_ghsa_bot/__init__.py +++ b/src/psrt_ghsa_bot/__init__.py @@ -1,3 +1,7 @@ """PSRT GHSA Bot package.""" __version__ = "0.1.0" + +from psrt_ghsa_bot.logging_config import setup_logging + +setup_logging() diff --git a/src/psrt_ghsa_bot/_monitoring.py b/src/psrt_ghsa_bot/_monitoring.py index d6e0fe2..d224162 100644 --- a/src/psrt_ghsa_bot/_monitoring.py +++ b/src/psrt_ghsa_bot/_monitoring.py @@ -4,6 +4,7 @@ from typing import Literal import sentry_sdk +from sentry_sdk import crons def init_sentry() -> None: @@ -37,8 +38,6 @@ def capture_checkin( return None try: - from sentry_sdk import crons - return crons.capture_checkin( monitor_slug=monitor_slug, status=status, @@ -70,6 +69,8 @@ def report_workflow_failure(workflow_name: str, run_id: str, conclusion: str) -> "workflow_name": workflow_name, "run_id": run_id, "conclusion": conclusion, - "workflow_url": f"https://github.com/{os.environ.get('GITHUB_REPOSITORY', 'unknown')}/actions/runs/{run_id}", + "workflow_url": ( + f"https://github.com/{os.environ.get('GITHUB_REPOSITORY', 'unknown')}/actions/runs/{run_id}" + ), }, ) diff --git a/src/psrt_ghsa_bot/app.py b/src/psrt_ghsa_bot/app.py index 64b1e19..c89c8aa 100644 --- a/src/psrt_ghsa_bot/app.py +++ b/src/psrt_ghsa_bot/app.py @@ -2,17 +2,21 @@ import base64 import datetime +import json import os import typing +from datetime import UTC +from http import HTTPStatus from cvelib.cve_api import CveApi from dotenv import load_dotenv from githubkit import AppAuthStrategy, GitHub +from githubkit.exception import RequestFailed load_dotenv() -PSRT_GITHUB_TEAM_SLUG = "psrt" +PSRT_GITHUB_TEAM_SLUG = "psrt" # TODO: configurable def get_repository_advisories( @@ -21,10 +25,6 @@ def get_repository_advisories( repo: str, ) -> typing.Iterable[dict[str, typing.Any]]: """Lists repository security advisories using the REST API.""" - import json - - from githubkit.exception import RequestFailed - try: # Use direct request instead of paginate to avoid validation issues response = github.rest.security_advisories.list_repository_advisories( @@ -36,16 +36,18 @@ def get_repository_advisories( yield from advisories except RequestFailed as e: # 404 means no advisories or no access - that's okay - if e.response.status_code == 404: + if e.response.status_code == HTTPStatus.NOT_FOUND: return raise def reserve_one_cve(cve_api: CveApi) -> str: """Reserves a single CVE ID.""" - resp = cve_api.reserve(count=1, random=True, year=str(datetime.date.today().year)) + resp = cve_api.reserve(count=1, random=True, year=str(datetime.datetime.now(tz=UTC).year)) cve_ids = [cve["cve_id"] for cve in resp["cve_ids"]] - assert len(cve_ids) == 1 + if len(cve_ids) != 1: + msg = f"Expected 1 CVE ID, got {len(cve_ids)}" + raise ValueError(msg) return cve_ids[0] @@ -89,6 +91,7 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non def main() -> None: + """Main entry point for cron.yml.""" gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() github = GitHub( AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key), @@ -104,10 +107,7 @@ def main() -> None: installations = github.rest.paginate( github.rest.apps.list_installations, ) - installation_count = 0 - for installation_data in installations: - installation_count += 1 - + for _installation_count, installation_data in enumerate(installations, start=1): installation_github = github.with_auth( github.auth.as_installation(installation_data.id), ) diff --git a/src/psrt_ghsa_bot/commands/authorization.py b/src/psrt_ghsa_bot/commands/authorization.py index 8f2d87a..d1fba4d 100644 --- a/src/psrt_ghsa_bot/commands/authorization.py +++ b/src/psrt_ghsa_bot/commands/authorization.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: from githubkit import GitHub +from http import HTTPStatus + @dataclass class AuthorizationResult: @@ -90,9 +92,10 @@ def _is_psrt_team_member(github: GitHub, username: str) -> bool: team_slug="psrt", username=username, ) - return response.status_code == 204 except Exception: return False + else: + return response.status_code == HTTPStatus.NO_CONTENT def _is_ghsa_collaborator( @@ -159,9 +162,10 @@ def _is_team_member( team_slug=team_slug, username=username, ) - return response.status_code == 204 except Exception: return False + else: + return response.status_code == HTTPStatus.NO_CONTENT def _is_repo_admin( @@ -190,7 +194,7 @@ def _is_repo_admin( if not response.parsed_data or not response.parsed_data.permission: return False - - return response.parsed_data.permission == "admin" except Exception: return False + else: + return response.parsed_data.permission == "admin" diff --git a/src/psrt_ghsa_bot/commands/executor.py b/src/psrt_ghsa_bot/commands/executor.py index 3fa7359..135df3a 100644 --- a/src/psrt_ghsa_bot/commands/executor.py +++ b/src/psrt_ghsa_bot/commands/executor.py @@ -8,6 +8,7 @@ import os from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING from cvelib.cve_api import CveApi @@ -42,9 +43,9 @@ def execute_command( repo: str, ghsa_id: str, ) -> CommandResult: - """Execute a parsed command like: + """Execute a parsed command. - "@ assign-cve" + Example: "@ assign-cve" These are based on src/psrt_ghsa_bot/commands/parser.py:AVAILABLE_COMMANDS and do an auth check before trying. @@ -120,7 +121,7 @@ def _handle_help(playwright_client: GitHubPlaywrightClient) -> CommandResult: return CommandResult(success=True, message=help_text) -def _handle_status(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: +def _handle_status(_cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: """Handle status command. Args: @@ -140,9 +141,6 @@ def _handle_status(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: cve_id = advisory.parsed_data.cve_id or "None assigned" created_at = advisory.parsed_data.created_at updated_at = advisory.parsed_data.updated_at - - from datetime import datetime - created = datetime.fromisoformat(created_at) days_old = (datetime.now(created.tzinfo) - created).days @@ -254,7 +252,7 @@ def _handle_assign_cve(cmd: Command, github: GitHub, owner: str, repo: str, ghsa return CommandResult( success=False, message=( - f"â„šī¸ **CVE Already Assigned**\n\n" + f"**CVE Already Assigned**\n\n" f"Advisory {ghsa_id} already has CVE ID {current_cve} assigned.\n\n" f"Use `status` to view advisory details or `reject {current_cve}` to remove it." ), diff --git a/src/psrt_ghsa_bot/commands/parser.py b/src/psrt_ghsa_bot/commands/parser.py index 52cd572..5378ad2 100644 --- a/src/psrt_ghsa_bot/commands/parser.py +++ b/src/psrt_ghsa_bot/commands/parser.py @@ -3,7 +3,7 @@ import os import re from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from typing import TypedDict @@ -32,6 +32,7 @@ class Command: """When the command was issued""" def __repr__(self) -> str: + """String repr for debugs.""" args_str = " ".join(self.arguments) if self.arguments else "(no args)" return f"Command({self.action} {args_str} by {self.author})" @@ -136,7 +137,7 @@ def parse_command( arguments = arguments_str.split() if arguments_str else [] if timestamp is None: - timestamp = datetime.now() + timestamp = datetime.now(tz=UTC) return Command( action=action, diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py index c6cca08..9bde4a5 100644 --- a/src/psrt_ghsa_bot/comment_processor.py +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -2,6 +2,7 @@ import base64 import contextlib +import logging import os from dataclasses import dataclass @@ -17,6 +18,8 @@ load_dotenv() +logger = logging.getLogger(__name__) + @dataclass class CommentProcessingStats: @@ -31,12 +34,12 @@ class CommentProcessingStats: def process_ghsa_comments( - github: GitHub, - playwright_client: GitHubPlaywrightClient, - state_manager: StateManager, - owner: str, - repo: str, - ghsa_id: str, + github: GitHub, + playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, + owner: str, + repo: str, + ghsa_id: str, ) -> int: """Process comments for a single GHSA. @@ -54,16 +57,19 @@ def process_ghsa_comments( ghsa_key = f"{owner}/{repo}/{ghsa_id}" try: - comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id, debug=True) - except Exception as e: - print(f"\n âš ī¸ Could not fetch comments: {e}") + comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id) + except PermissionError as e: + logger.warning("Access denied to %s: %s", ghsa_id, e) + return 0 + except Exception: + logger.exception("Failed to fetch comments for %s", ghsa_id) return 0 if not comments: - print("\n đŸ’Ŧ No comments found on this GHSA") + logger.debug("No comments found on %s", ghsa_id) return 0 - print(f"\n đŸ’Ŧ Found {len(comments)} comment(s)") + logger.info("Processing %d comments on %s", len(comments), ghsa_id) commands_executed = 0 for comment in comments: comment_id = comment.id @@ -71,33 +77,31 @@ def process_ghsa_comments( body = comment.body if author == playwright_client.username: - # don't actually wanna process our own comments :) + logger.debug("Skipping bot's own comment: %s", comment_id) continue cmd = parse_command(body, author, comment_id, playwright_client.username, comment.created_at) if cmd is None: - body_preview = body[:50].replace('\n', ' ') if body else "(empty)" - print(f" ⏊ @{author}: no command | body: {body_preview}... | looking for: @{playwright_client.username}") + logger.debug("No command in comment from @%s", author) continue if state_manager.is_command_processed(ghsa_key, comment_id, body, author): - print(f" ⏊ Command from @{author} already processed: {cmd.action}") + logger.debug("Command already processed: %s from @%s", cmd.action, author) continue - print(f"\n đŸŽ¯ New command from @{author}: @{playwright_client.username} {cmd.action}") + logger.info("Executing command: %s from @%s on %s", cmd.action, author, ghsa_id) try: result = execute_command(cmd, github, playwright_client, owner, repo, ghsa_id) post_ghsa_comment(playwright_client, owner, repo, ghsa_id, result.message) state_manager.mark_command_processed(ghsa_key, comment_id, body, author) commands_executed += 1 - print(f" ✅ Command executed successfully") - except Exception as e: - print(f" ❌ Command failed: {e}") + logger.info("Command executed successfully: %s", cmd.action) + except Exception: + logger.exception("Command execution failed: %s from @%s on %s", cmd.action, author, ghsa_id) error_message = ( f"❌ **Command Execution Failed**\n\n" - f"@{author}, an error occurred while processing your command:\n\n" - f"```\n{e!s}\n```\n\n" + f"@{author}, an error occurred while processing your command.\n\n" f"Please contact the PSRT team if this error persists." ) @@ -108,9 +112,9 @@ def process_ghsa_comments( def process_all_comments( - github: GitHub, - playwright_client: GitHubPlaywrightClient, - state_manager: StateManager, + github: GitHub, + playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, ) -> CommentProcessingStats: """Process comments for all GHSAs across all installations. @@ -126,7 +130,9 @@ def process_all_comments( installations = github.rest.paginate(github.rest.apps.list_installations) for installation_data in installations: - print(f"\nđŸ“Ļ Installation: {installation_data.account.login}") + installation_name = installation_data.account.login + logger.info("Processing installation: %s", installation_name) + installation_github = github.with_auth(github.auth.as_installation(installation_data.id)) repos = installation_github.rest.paginate( installation_github.rest.apps.list_repos_accessible_to_installation, @@ -143,17 +149,18 @@ def process_all_comments( continue count = len(advisories) - print(f" 📂 {owner}/{repo_name}: {count} {'advisory' if count == 1 else 'advisories'}") + logger.debug("Found %d advisories in %s/%s", count, owner, repo_name) for advisory in advisories: ghsa_id = advisory["ghsa_id"] state_str = advisory["state"] if state_str not in ("triage", "draft"): + logger.debug("Skipping %s (state: %s)", ghsa_id, state_str) continue stats.ghsas_checked += 1 - print(f" 🔍 Checking {ghsa_id} ({state_str})...", end=" ") + logger.info("Checking GHSA: %s/%s/%s (state: %s)", owner, repo_name, ghsa_id, state_str) try: commands_executed = process_ghsa_comments( installation_github, @@ -163,17 +170,13 @@ def process_all_comments( repo_name, ghsa_id, ) - if commands_executed > 0: - print(f"✅ {commands_executed} command(s) executed") - else: - print("â­ī¸ No new commands") stats.commands_executed += commands_executed - except Exception as e: - print(f"❌ Error: {e}") + except Exception: + logger.exception("Error processing %s", ghsa_id) stats.errors += 1 - except Exception as e: - print(f" âš ī¸ Error accessing {owner}/{repo_name}: {e}") + except Exception: + logger.exception("Error accessing repository %s/%s", owner, repo_name) stats.errors += 1 return stats @@ -181,36 +184,36 @@ def process_all_comments( def main() -> None: """Cmment processing machine.""" - print("🤖 PSRT GHSA Bot - Comment Processor") - print("=" * 50) + logger.info("PSRT GHSA Bot - Comment Processor") + logger.info("=" * 50) - print("\n📡 Initializing GitHub API client...") + logger.info("Initializing GitHub API client...") gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() github = GitHub(AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key)) - print("💾 Loading state manager...") + logger.info("Loading state manager...") state_manager = StateManager() state_manager.load() - print("🌐 Starting Playwright browser...") + logger.info("Starting Playwright browser...") with GitHubPlaywrightClient() as playwright_client: - print("🔐 Authenticating to GitHub...") + logger.info("Authenticating to GitHub...") playwright_client.authenticate() - print("✅ Authentication successful!\n") + logger.info("Authentication successful!") - print("🔍 Processing comments across all installations...") + logger.info("Processing comments across all installations...") stats = process_all_comments(github, playwright_client, state_manager) - print("\n" + "=" * 50) - print("📊 Processing Summary:") - print(f" GHSAs checked: {stats.ghsas_checked}") - print(f" Commands executed: {stats.commands_executed}") - print(f" Errors: {stats.errors}") - print("=" * 50) + logger.info("=" * 50) + logger.info("Processing Summary:") + logger.info(" GHSAs checked: %d", stats.ghsas_checked) + logger.info(" Commands executed: %d", stats.commands_executed) + logger.info(" Errors: %d", stats.errors) + logger.info("=" * 50) - print("\n💾 Saving state...") + logger.info("Saving state...") state_manager.save() - print("✅ Done!") + logger.info("Done!") if __name__ == "__main__": diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index fab3399..98e80a8 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -5,11 +5,14 @@ """ import json +import logging import subprocess import sys from psrt_ghsa_bot._monitoring import capture_checkin, init_sentry, report_workflow_failure +logger = logging.getLogger(__name__) + def check_workflow_health() -> None: """Check the health of configured workflows and report to Sentry.""" @@ -19,28 +22,16 @@ def check_workflow_health() -> None: workflows_to_check = [ {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": "psrt-ghsa-cron"}, - # {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, + # {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, # noqa: ERA001, E501 ] all_healthy = True for workflow in workflows_to_check: - print(f"\nChecking workflow: {workflow['name']}") - - """ - This is like: - ➜ gh run list --workflow cron.yml --json conclusion,status,databaseId --limit 1 - [ - { - "conclusion": "success", - "databaseId": 19509767419, - "status": "completed" - } - ] - """ - - result = subprocess.run( - [ + logger.info("Checking workflow: %s", workflow["name"]) + + result = subprocess.run( # noqa: S603 + [ # noqa: S607 "gh", "run", "list", @@ -57,43 +48,43 @@ def check_workflow_health() -> None: ) if result.returncode != 0: - print(f"âš ī¸ Failed to get workflow runs: {result.stderr}") + logger.warning("Failed to get workflow runs: %s", result.stderr) all_healthy = False continue runs = json.loads(result.stdout) if not runs: - print(f"âš ī¸ No runs found for {workflow['name']}") + logger.warning("No runs found for %s", workflow["name"]) continue completed_runs = [r for r in runs if r["status"] == "completed"] if not completed_runs: - print(f"â„šī¸ No completed runs yet for {workflow['name']}") + logger.info("No completed runs yet for %s", workflow["name"]) continue latest_run = completed_runs[0] conclusion = latest_run["conclusion"] run_id = latest_run["databaseId"] - print(f" Latest run: {run_id} - {conclusion}") + logger.info("Latest run: %s - %s", run_id, conclusion) if conclusion in ["failure", "timed_out", "cancelled"]: - print(f"❌ Workflow failed with status: {conclusion}") + logger.error("Workflow failed with status: %s", conclusion) report_workflow_failure(workflow["name"], str(run_id), conclusion) capture_checkin(workflow["monitor_slug"], "error") all_healthy = False elif conclusion == "success": - print("✅ Workflow succeeded") + logger.info("Workflow succeeded") capture_checkin(workflow["monitor_slug"], "ok") else: - print(f"âš ī¸ Unexpected conclusion: {conclusion}") + logger.warning("Unexpected conclusion: %s", conclusion) all_healthy = False if all_healthy: capture_checkin("psrt-health-monitor", "ok") - print("\n✅ All workflows healthy") + logger.info("All workflows healthy") else: capture_checkin("psrt-health-monitor", "error") - print("\n❌ Some workflows are unhealthy") + logger.error("Some workflows are unhealthy") sys.exit(1) diff --git a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py index ca7ed80..6020ec0 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -5,8 +5,9 @@ automation to extract comment data from the GitHub web UI. """ +import logging from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING from playwright.sync_api import Locator @@ -15,6 +16,8 @@ if TYPE_CHECKING: from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +logger = logging.getLogger(__name__) + @dataclass class GHSAComment: @@ -42,7 +45,7 @@ def get_ghsa_comments( client: GitHubPlaywrightClient, owner: str, repo: str, - ghsa_id: str, debug: bool = False, + ghsa_id: str, ) -> list[GHSAComment]: """Get all comments from a GitHub Security Advisory using Playwright. @@ -54,7 +57,6 @@ def get_ghsa_comments( owner: Repository owner (organization or user) repo: Repository name ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) - debug: Enable debug output Returns: List of GHSAComment objects in chronological order @@ -72,8 +74,17 @@ def get_ghsa_comments( """ client.navigate_to_ghsa(owner, repo, ghsa_id) + if "404" in client.page.title() or client.page.locator("text=404").count() > 0: + msg = f"Advisory {ghsa_id} not found or bot lacks access (collaborator permissions required)" + raise PermissionError(msg) + + try: + client.page.wait_for_selector(".timeline-comment, .js-comment", timeout=10000, state="attached") + except PlaywrightTimeoutError: + logger.debug("Timeout waiting for comments to load on %s", ghsa_id) + _load_all_comments(client) - return _extract_comments(client, debug=debug) + return _extract_comments(client) def _load_all_comments(client: GitHubPlaywrightClient) -> None: @@ -117,58 +128,53 @@ def _load_all_comments(client: GitHubPlaywrightClient) -> None: break -def _extract_comments(client: GitHubPlaywrightClient, debug: bool = False) -> list[GHSAComment]: +def _extract_comments(client: GitHubPlaywrightClient) -> list[GHSAComment]: """Extract all comment data from the current page. Uses multiple fallback selectors to handle GitHub UI changes. Args: client: GitHubPlaywrightClient instance - debug: Enable debug output Returns: List of parsed GHSAComment objects """ comments: list[GHSAComment] = [] comment_selectors = [ + ".js-comment-container .timeline-comment", ".timeline-comment", - ".TimelineItem-body", - "[data-hpc]", ".js-comment", + "div.TimelineItem.js-comment-container", ] comment_elements: list[Locator] = [] for selector in comment_selectors: try: elements = client.page.locator(selector).all() - if debug and elements: - print(f"\n 🔍 DEBUG: Found {len(elements)} elements with selector '{selector}'") + logger.debug("Selector '%s' found %d elements", selector, len(elements)) if elements: comment_elements = elements break except Exception as e: - if debug: - print(f"\n 🔍 DEBUG: Selector '{selector}' failed: {e}") + logger.debug("Selector '%s' failed: %s", selector, e) continue if not comment_elements: - if debug: - print("\n 🔍 DEBUG: No comment elements found with any selector") + logger.debug("No comment elements found with any selector") return comments - if debug: - print(f"\n 🔍 DEBUG: Parsing {len(comment_elements)} comment elements") + logger.debug("Parsing %d comment elements", len(comment_elements)) for idx, element in enumerate(comment_elements): try: comment = _parse_comment_element(element, idx) if comment: comments.append(comment) - if debug: - print(f"\n 🔍 DEBUG: Parsed comment from @{comment.author}: {comment.body[:30]}...") + logger.debug("Parsed comment from @%s: %s", comment.author, comment.body[:30]) + else: + logger.debug("Element %d returned None (not a comment)", idx) except Exception as e: - if debug: - print(f"\n 🔍 DEBUG: Failed to parse element {idx}: {e}") + logger.debug("Failed to parse element %d: %s", idx, e) continue return comments @@ -192,6 +198,7 @@ def _parse_comment_element(element: Locator, fallback_idx: int) -> GHSAComment | if comment_id: break except Exception: + logger.exception("Failed to get attribute %s", attr) continue if not comment_id: @@ -214,9 +221,11 @@ def _parse_comment_element(element: Locator, fallback_idx: int) -> GHSAComment | author = author.strip() break except Exception: + logger.exception("Failed to get author with selector %s", selector) continue if not author: + logger.debug("Element %d: No author found, skipping", fallback_idx) return None body = None @@ -235,6 +244,7 @@ def _parse_comment_element(element: Locator, fallback_idx: int) -> GHSAComment | body = body.strip() break except Exception: + logger.exception("Failed to get body with selector %s", selector) continue if not body: @@ -279,9 +289,10 @@ def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: if datetime_str: return datetime.fromisoformat(datetime_str) except Exception: + logger.exception("Failed to get timestamp with selector %s", selector) continue - return datetime.now() + return datetime.now(tz=UTC) def _is_bot_author(element: Locator, author: str) -> bool: diff --git a/src/psrt_ghsa_bot/polyfills/comments/post_comment.py b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py index 863b580..be5b5b3 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/post_comment.py +++ b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py @@ -1,5 +1,6 @@ """For posting comments to GHSA using Playwright.""" +import logging from typing import TYPE_CHECKING from playwright.sync_api import TimeoutError as PlaywrightTimeoutError @@ -7,6 +8,8 @@ if TYPE_CHECKING: from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +logger = logging.getLogger(__name__) + def post_ghsa_comment( client: GitHubPlaywrightClient, @@ -79,7 +82,7 @@ def _fill_comment_form(client: GitHubPlaywrightClient, comment_body: str) -> Non "Could not find comment textarea. The page structure may have changed, " "or you may not have permission to comment on this advisory." ) - raise RuntimeError(msg) + raise RuntimeError(msg) from None def _submit_comment(client: GitHubPlaywrightClient) -> None: @@ -97,7 +100,7 @@ def _submit_comment(client: GitHubPlaywrightClient) -> None: submit_button.click() except PlaywrightTimeoutError: msg = "Could not find comment submit button. The page structure may have changed." - raise RuntimeError(msg) + raise RuntimeError(msg) from None def _wait_for_comment_posted( @@ -133,17 +136,17 @@ def _wait_for_comment_posted( return comment_id.replace("advisory-comment-", "") return "comment-posted" except Exception: - continue + logger.exception("Error checking timeline item: %s", item) - msg = ( - f"Comment was submitted but could not be found on the page within {timeout}ms. " - "It may have been posted successfully but not yet visible." - ) - raise RuntimeError(msg) + msg = ( + f"Comment was submitted but could not be found on the page within {timeout}ms. " + "It may have been posted successfully but not yet visible." + ) + raise RuntimeError(msg) from None except PlaywrightTimeoutError: msg = ( f"Comment was submitted but could not be found on the page within {timeout}ms. " "It may have been posted successfully but not yet visible." ) - raise RuntimeError(msg) + raise RuntimeError(msg) from None diff --git a/src/psrt_ghsa_bot/polyfills/playwright_base.py b/src/psrt_ghsa_bot/polyfills/playwright_base.py index 5f5d301..0313fd0 100644 --- a/src/psrt_ghsa_bot/polyfills/playwright_base.py +++ b/src/psrt_ghsa_bot/polyfills/playwright_base.py @@ -4,15 +4,19 @@ GitHub web UI interactions that are not available through the API. """ +import logging import os from pathlib import Path from typing import Any, Self +import pyotp from dotenv import load_dotenv from playwright.sync_api import Browser, BrowserContext, Page, Playwright, sync_playwright load_dotenv() +logger = logging.getLogger(__name__) + class GitHubPlaywrightClient: """Client for automating GitHub UI interactions using Playwright. @@ -24,6 +28,7 @@ class GitHubPlaywrightClient: def __init__( self, + *, headless: bool = True, auth_token: str | None = None, storage_state_path: str | None = None, @@ -114,7 +119,7 @@ def context(self) -> BrowserContext: raise RuntimeError(msg) return self._context - def authenticate(self, force: bool = False) -> None: + def authenticate(self, *, force: bool = False) -> None: """Authenticate to GitHub using storage state or credentials. Authentication methods tried in order: @@ -171,16 +176,16 @@ def authenticate_manual(self, timeout: int = 300_000) -> None: try: self.page.wait_for_url("https://github.com/**", timeout=timeout) self.page.wait_for_selector("button[aria-label='Open user navigation menu']", timeout=10000) - except Exception as e: - msg = f"Manual authentication failed or timed out: {e}" - raise RuntimeError(msg) + except Exception as err: + msg = f"Manual authentication failed or timed out: {err}" + raise RuntimeError(msg) from err storage_state_file = Path(self.storage_state_path) storage_state_file.parent.mkdir(parents=True, exist_ok=True) self.context.storage_state(path=str(storage_state_file)) - print("\n✅ Authentication successful! Session saved.") - print(f" State saved to: {storage_state_file}\n") + logger.info("Authentication successful! Session saved.") + logger.info("State saved to: %s", storage_state_file) def _login_with_credentials(self, username: str, password: str) -> None: """Perform automated login with username and password. @@ -214,11 +219,9 @@ def _login_with_credentials(self, username: str, password: str) -> None: raise RuntimeError(msg) try: - import pyotp - totp = pyotp.TOTP(otp_secret) otp_code = totp.now() - print(" Using OTP code for 2FA...") + logger.info("Using OTP code for 2FA...") self.page.locator('input[name="app_otp"]').fill(otp_code) self.page.locator('button[type="submit"]:has-text("Verify")').click() @@ -226,7 +229,7 @@ def _login_with_credentials(self, username: str, password: str) -> None: except ImportError: msg = "2FA required but pyotp not installed. did you 'uv sync' the project?" - raise RuntimeError(msg) + raise RuntimeError(msg) from None if not self._is_authenticated(): msg = "Login failed - authentication check failed" @@ -250,10 +253,10 @@ def _is_authenticated(self) -> bool: has_user_session = "user_session" in auth_cookies has_dotcom_user = "dotcom_user" in auth_cookies - - return has_user_session and has_dotcom_user except Exception: return False + else: + return has_user_session and has_dotcom_user def navigate_to_ghsa(self, owner: str, repo: str, ghsa_id: str) -> None: """Navigate to a specific GitHub Security Advisory page. diff --git a/src/psrt_ghsa_bot/state.py b/src/psrt_ghsa_bot/state.py index b937d37..be16fe6 100644 --- a/src/psrt_ghsa_bot/state.py +++ b/src/psrt_ghsa_bot/state.py @@ -102,7 +102,7 @@ def load(self) -> BotState: if self.state_file.exists(): try: - with open(self.state_file) as f: + with self.state_file.open() as f: data = json.load(f) self._state = BotState.from_dict(data) except (json.JSONDecodeError, KeyError): @@ -120,7 +120,7 @@ def save(self) -> None: self._state.last_run = datetime.now(UTC).isoformat() self.state_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.state_file, "w") as f: + with self.state_file.open("w") as f: json.dump(self._state.to_dict(), f, indent=2) def get_ghsa_state(self, ghsa_id: str) -> GHSAState: diff --git a/src/psrt_ghsa_bot/utils/__init__.py b/src/psrt_ghsa_bot/utils/__init__.py new file mode 100644 index 0000000..f5d3dc7 --- /dev/null +++ b/src/psrt_ghsa_bot/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions.""" diff --git a/src/psrt_ghsa_bot/utils/bot_helpers.py b/src/psrt_ghsa_bot/utils/bot_helpers.py new file mode 100644 index 0000000..5dd539a --- /dev/null +++ b/src/psrt_ghsa_bot/utils/bot_helpers.py @@ -0,0 +1,21 @@ +"""Helpers to be used by the bot when needed.""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + +logger = logging.getLogger(__name__) + + +def take_debug_screenshot(client: GitHubPlaywrightClient, ghsa_id: str) -> None: + """Take a screenshot, saving locally to disk. + + Args: + client: Playwright client + ghsa_id: GHSA ID for the advisory + """ + logger.debug("Taking debug screenshot for %s", ghsa_id) + client.page.screenshot(path=f"debug_{ghsa_id}.png") + logger.info("Screenshot saved to debug_%s.png", ghsa_id) diff --git a/state.json b/state.json index d5b60cd..d030b6a 100644 --- a/state.json +++ b/state.json @@ -1,12 +1,12 @@ { - "last_run": "2025-11-19T21:17:09.904204+00:00", + "last_run": "2025-11-20T00:20:10.935213+00:00", "ghsas": { "jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf": { "last_comment_id": "comment-17", "last_processed_at": "2025-11-19T21:12:48.590575+00:00", "processed_commands": [ - "ed3d1e88521c7bc4", "4d55106d50489f70", + "ed3d1e88521c7bc4", "b47d14abe3060256" ], "commands_processed_count": 3 diff --git a/tests/test_comments.py b/tests/test_comments.py index 8d22226..6417f4b 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -220,19 +220,18 @@ def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: client.authenticate() # Try to get comments from a non-existent GHSA - # This should either return empty list or raise a reasonable error - try: - comments = get_ghsa_comments( + # Should raise PermissionError for 404/access denied + with pytest.raises((PermissionError, Exception)) as exc_info: + get_ghsa_comments( client, owner="jolt-org", repo="ghsa-testing", - ghsa_id="GHSA-0000-0000-0000", # Invalid GHSA + ghsa_id="GHSA-0000-0000-0000", ) - # If it doesn't error, should return empty list - assert isinstance(comments, list) - except Exception as e: - # If it does error, should be a reasonable error message - assert "GHSA" in str(e) or "404" in str(e) or "not found" in str(e).lower() + + if exc_info.type is not PermissionError: + error_msg = str(exc_info.value).lower() + assert "ghsa" in error_msg or "404" in error_msg or "not found" in error_msg @pytest.mark.skip(reason="Integration test - requires specific test GHSA with known comment count") diff --git a/uv.lock b/uv.lock index dc2ebdd..b1ec4ad 100644 --- a/uv.lock +++ b/uv.lock @@ -47,11 +47,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.10.5" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] @@ -114,14 +114,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -205,7 +205,7 @@ wheels = [ [[package]] name = "githubkit" -version = "0.13.5" +version = "0.13.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -214,9 +214,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/c2/4cdff48431a373bfa9e01c1ccfdb2f1b8f3fd27860a3be4001b0122d4a26/githubkit-0.13.5.tar.gz", hash = "sha256:d47f1aea19473a8aaef6a6413debfa420740f0207de42d32e98758cb6728fb12", size = 2286056, upload-time = "2025-10-25T16:12:42.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/74/a61ac8110585951a21bdc0ca39b0a41cd069a5c6d9e3a0cf607519fe30d2/githubkit-0.13.6.tar.gz", hash = "sha256:77f8f59bedbd503d1b581e5a93d993416467ee534f8ce7bd36e75282a8f51785", size = 2621144, upload-time = "2025-11-09T10:06:10.691Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/45/c765290bf71f32c545829495924b2ecf058166385dec8af3c108bdc6617f/githubkit-0.13.5-py3-none-any.whl", hash = "sha256:665d67f90258044980585cc71e1e19d78e755b0efea69728520373cfc8d28f86", size = 6046859, upload-time = "2025-10-25T16:12:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/6c87f4ba88af118712e90e90b949708b6296aa75fedf4fa3d8c54fb73a09/githubkit-0.13.6-py3-none-any.whl", hash = "sha256:8fe3c93365d04039479db347b122ffedc3812e74ea2a3f6e745f2d0039e727e2", size = 6360763, upload-time = "2025-11-09T10:06:09.126Z" }, ] [package.optional-dependencies] @@ -385,21 +385,21 @@ wheels = [ [[package]] name = "playwright" -version = "1.55.0" +version = "1.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet" }, { name = "pyee" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, - { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, - { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, - { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, - { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, - { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, - { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/a5362cee43f844509f1f10d8a27c9cc0e2f7bdce5353d304d93b2151c1b1/playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c", size = 40611424, upload-time = "2025-11-11T18:39:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/ef/95/347eef596d8778fb53590dc326c344d427fa19ba3d42b646fce2a4572eb3/playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b", size = 39400228, upload-time = "2025-11-11T18:39:13.915Z" }, + { url = "https://files.pythonhosted.org/packages/b9/54/6ad97b08b2ca1dfcb4fbde4536c4f45c0d9d8b1857a2d20e7bbfdf43bf15/playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65", size = 40611424, upload-time = "2025-11-11T18:39:17.093Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/6d409e37e82cdd5dda3df1ab958130ae32b46e42458bd4fc93d7eb8749cb/playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c", size = 46263122, upload-time = "2025-11-11T18:39:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/84/fb292cc5d45f3252e255ea39066cd1d2385c61c6c1596548dfbf59c88605/playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34", size = 46110645, upload-time = "2025-11-11T18:39:24.005Z" }, + { url = "https://files.pythonhosted.org/packages/61/bd/8c02c3388ae14edc374ac9f22cbe4e14826c6a51b2d8eaf86e89fabee264/playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721", size = 35639837, upload-time = "2025-11-11T18:39:27.174Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/f13b538fbc6b7a00152f4379054a49f6abc0bf55ac86f677ae54bc49fb82/playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94", size = 35639843, upload-time = "2025-11-11T18:39:30.851Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c7/3ee8b556107995846576b4fe42a08ed49b8677619421f2afacf6ee421138/playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f", size = 31248959, upload-time = "2025-11-11T18:39:33.998Z" }, ] [[package]] @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.3" +version = "2.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -473,39 +473,48 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, - { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, - { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, - { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, - { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, - { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] @@ -660,65 +669,65 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, - { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, - { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, - { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, - { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, - { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, - { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, - { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, - { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, - { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, - { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, - { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, - { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, ] [[package]] name = "ruff" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, - { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, - { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, - { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, - { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] [[package]] @@ -763,27 +772,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.1a25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/6b/e73bc3c1039ea72936158a08313155a49e5aa5e7db5205a149fe516a4660/ty-0.0.1a25.tar.gz", hash = "sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511", size = 4403670, upload-time = "2025-10-29T19:40:23.647Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/3b/4457231238a2eeb04cba4ba7cc33d735be68ee46ca40a98ae30e187de864/ty-0.0.1a25-py3-none-linux_armv6l.whl", hash = "sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d", size = 8878803, upload-time = "2025-10-29T19:39:42.243Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/a328713dd310018fc7a381693d8588185baa2fdae913e01a6839187215df/ty-0.0.1a25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703", size = 8695667, upload-time = "2025-10-29T19:39:45.179Z" }, - { url = "https://files.pythonhosted.org/packages/22/e8/5707939118992ced2bf5385adc3ede7723c1b717b07ad14c495eea1e47b4/ty-0.0.1a25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf", size = 8159012, upload-time = "2025-10-29T19:39:47.011Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fb/ff313aa71602225cd78f1bce3017713d6d1b1c1e0fa8101ead4594a60d95/ty-0.0.1a25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7", size = 8433675, upload-time = "2025-10-29T19:39:48.443Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8d/cc7e7fb57215a15b575a43ed042bdd92971871e0decec1b26d2e7d969465/ty-0.0.1a25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447", size = 8668456, upload-time = "2025-10-29T19:39:50.412Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6d/d7bf5909ed2dcdcbc1e2ca7eea80929893e2d188d9c36b3fcb2b36532ff6/ty-0.0.1a25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01", size = 9023543, upload-time = "2025-10-29T19:39:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b8/72bcefb4be32e5a84f0b21de2552f16cdb4cae3eb271ac891c8199c26b1a/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a", size = 9700013, upload-time = "2025-10-29T19:39:57.283Z" }, - { url = "https://files.pythonhosted.org/packages/90/0d/cf7e794b840cf6b0bbecb022e593c543f85abad27a582241cf2095048cb1/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b", size = 9372574, upload-time = "2025-10-29T19:40:04.532Z" }, - { url = "https://files.pythonhosted.org/packages/1e/71/2d35e7d51b48eabd330e2f7b7e0bce541cbd95950c4d2f780e85f3366af1/ty-0.0.1a25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62", size = 9535726, upload-time = "2025-10-29T19:40:06.548Z" }, - { url = "https://files.pythonhosted.org/packages/57/d3/01ecc23bbd8f3e0dfbcf9172d06d84e88155c5f416f1491137e8066fd859/ty-0.0.1a25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc", size = 9003380, upload-time = "2025-10-29T19:40:08.683Z" }, - { url = "https://files.pythonhosted.org/packages/de/f9/cde9380d8a1a6ca61baeb9aecb12cbec90d489aa929be55cd78ad5c2ccd9/ty-0.0.1a25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068", size = 8401833, upload-time = "2025-10-29T19:40:10.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/39/0acf3625b0c495011795a391016b572f97a812aca1d67f7a76621fdb9ebf/ty-0.0.1a25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9", size = 8706761, upload-time = "2025-10-29T19:40:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/25/73/7de1648f3563dd9d416d36ab5f1649bfd7b47a179135027f31d44b89a246/ty-0.0.1a25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979", size = 8792426, upload-time = "2025-10-29T19:40:14.553Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8a/b6e761a65eac7acd10b2e452f49b2d8ae0ea163ca36bb6b18b2dadae251b/ty-0.0.1a25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35", size = 9103991, upload-time = "2025-10-29T19:40:16.332Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/9324ae947fcc4322470326cf8276a3fc2f08dc82adec1de79d963fdf7af5/ty-0.0.1a25-py3-none-win32.whl", hash = "sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445", size = 8387095, upload-time = "2025-10-29T19:40:18.368Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2b/cb12cbc7db1ba310aa7b1de9b4e018576f653105993736c086ee67d2ec02/ty-0.0.1a25-py3-none-win_amd64.whl", hash = "sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb", size = 9059225, upload-time = "2025-10-29T19:40:20.278Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c1/f6be8cdd0bf387c1d8ee9d14bb299b7b5d2c0532f550a6693216a32ec0c5/ty-0.0.1a25-py3-none-win_arm64.whl", hash = "sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d", size = 8536832, upload-time = "2025-10-29T19:40:22.014Z" }, +version = "0.0.1a27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/65/3592d7c73d80664378fc90d0a00c33449a99cbf13b984433c883815245f3/ty-0.0.1a27.tar.gz", hash = "sha256:d34fe04979f2c912700cbf0919e8f9b4eeaa10c4a2aff7450e5e4c90f998bc28", size = 4516059, upload-time = "2025-11-18T21:55:18.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/05/7945aa97356446fd53ed3ddc7ee02a88d8ad394217acd9428f472d6b109d/ty-0.0.1a27-py3-none-linux_armv6l.whl", hash = "sha256:3cbb735f5ecb3a7a5f5b82fb24da17912788c109086df4e97d454c8fb236fbc5", size = 9375047, upload-time = "2025-11-18T21:54:31.577Z" }, + { url = "https://files.pythonhosted.org/packages/69/4e/89b167a03de0e9ec329dc89bc02e8694768e4576337ef6c0699987681342/ty-0.0.1a27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4a6367236dc456ba2416563301d498aef8c6f8959be88777ef7ba5ac1bf15f0b", size = 9169540, upload-time = "2025-11-18T21:54:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/38/07/e62009ab9cc242e1becb2bd992097c80a133fce0d4f055fba6576150d08a/ty-0.0.1a27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8e93e231a1bcde964cdb062d2d5e549c24493fb1638eecae8fcc42b81e9463a4", size = 8711942, upload-time = "2025-11-18T21:54:36.3Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/f35716ec15406f13085db52e762a3cc663c651531a8124481d0ba602eca0/ty-0.0.1a27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5b6a8166b60117da1179851a3d719cc798bf7e61f91b35d76242f0059e9ae1d", size = 8984208, upload-time = "2025-11-18T21:54:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/2d/79/486a3374809523172379768de882c7a369861165802990177fe81489b85f/ty-0.0.1a27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfbe8b0e831c072b79a078d6c126d7f4d48ca17f64a103de1b93aeda32265dc5", size = 9157209, upload-time = "2025-11-18T21:54:42.664Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/9a7c8efcb327197d7d347c548850ef4b54de1c254981b65e8cd0672dc327/ty-0.0.1a27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90e09678331552e7c25d7eb47868b0910dc5b9b212ae22c8ce71a52d6576ddbb", size = 9519207, upload-time = "2025-11-18T21:54:45.311Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7b4680683e83204b9edec551bb91c21c789ebc586b949c5218157ee474b7/ty-0.0.1a27-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:88c03e4beeca79d85a5618921e44b3a6ea957e0453e08b1cdd418b51da645939", size = 10148794, upload-time = "2025-11-18T21:54:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/89/21/8b961b0ab00c28223f06b33222427a8e31aa04f39d1b236acc93021c626c/ty-0.0.1a27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ece5811322789fefe22fc088ed36c5879489cd39e913f9c1ff2a7678f089c61", size = 9900563, upload-time = "2025-11-18T21:54:51.214Z" }, + { url = "https://files.pythonhosted.org/packages/85/eb/95e1f0b426c2ea8d443aa923fcab509059c467bbe64a15baaf573fea1203/ty-0.0.1a27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f2ccb4f0fddcd6e2017c268dfce2489e9a36cb82a5900afe6425835248b1086", size = 9926355, upload-time = "2025-11-18T21:54:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/f5/78/40e7f072049e63c414f2845df780be3a494d92198c87c2ffa65e63aecf3f/ty-0.0.1a27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33450528312e41d003e96a1647780b2783ab7569bbc29c04fc76f2d1908061e3", size = 9480580, upload-time = "2025-11-18T21:54:56.617Z" }, + { url = "https://files.pythonhosted.org/packages/18/da/f4a2dfedab39096808ddf7475f35ceb750d9a9da840bee4afd47b871742f/ty-0.0.1a27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0a9ac635deaa2b15947701197ede40cdecd13f89f19351872d16f9ccd773fa1", size = 8957524, upload-time = "2025-11-18T21:54:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/21/ea/26fee9a20cf77a157316fd3ab9c6db8ad5a0b20b2d38a43f3452622587ac/ty-0.0.1a27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:797fb2cd49b6b9b3ac9f2f0e401fb02d3aa155badc05a8591d048d38d28f1e0c", size = 9201098, upload-time = "2025-11-18T21:55:01.845Z" }, + { url = "https://files.pythonhosted.org/packages/b0/53/e14591d1275108c9ae28f97ac5d4b93adcc2c8a4b1b9a880dfa9d07c15f8/ty-0.0.1a27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7fe81679a0941f85e98187d444604e24b15bde0a85874957c945751756314d03", size = 9275470, upload-time = "2025-11-18T21:55:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/37/44/e2c9acecac70bf06fb41de285e7be2433c2c9828f71e3bf0e886fc85c4fd/ty-0.0.1a27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:355f651d0cdb85535a82bd9f0583f77b28e3fd7bba7b7da33dcee5a576eff28b", size = 9592394, upload-time = "2025-11-18T21:55:06.542Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a7/4636369731b24ed07c2b4c7805b8d990283d677180662c532d82e4ef1a36/ty-0.0.1a27-py3-none-win32.whl", hash = "sha256:61782e5f40e6df622093847b34c366634b75d53f839986f1bf4481672ad6cb55", size = 8783816, upload-time = "2025-11-18T21:55:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/b76487725628d9e81d9047dc0033a5e167e0d10f27893d04de67fe1a9763/ty-0.0.1a27-py3-none-win_amd64.whl", hash = "sha256:c682b238085d3191acddcf66ef22641562946b1bba2a7f316012d5b2a2f4de11", size = 9616833, upload-time = "2025-11-18T21:55:12.457Z" }, + { url = "https://files.pythonhosted.org/packages/3a/db/c7cd5276c8f336a3cf87992b75ba9d486a7cf54e753fcd42495b3bc56fb7/ty-0.0.1a27-py3-none-win_arm64.whl", hash = "sha256:e146dfa32cbb0ac6afb0cb65659e87e4e313715e68d76fe5ae0a4b3d5b912ce8", size = 9137796, upload-time = "2025-11-18T21:55:15.897Z" }, ] [[package]] From a1a657558a4218e0a57d36afd7c5c3abac295280 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 20:58:12 -0600 Subject: [PATCH 25/64] update tests pasing, source tests for new files, --- .gitignore | 3 +- Makefile | 1 + pyproject.toml | 3 +- tests/PLAYWRIGHT_FULL.test.example | 8 + tests/test_commands.py | 401 +++++++++++++++++++++++++++++ tests/test_comments.py | 63 ++--- tests/test_executor.py | 242 +++++++++++++++++ tests/test_playwright_base.py | 14 +- tests/test_state.py | 207 +++++++++++++++ 9 files changed, 902 insertions(+), 40 deletions(-) create mode 100644 tests/PLAYWRIGHT_FULL.test.example create mode 100644 tests/test_commands.py create mode 100644 tests/test_executor.py create mode 100644 tests/test_state.py diff --git a/.gitignore b/.gitignore index 8be575a..cd64451 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,5 @@ playwright-videos/ playwright-traces/ *.webm trace.zip -debug_*.png \ No newline at end of file +debug_*.png +tests/PLAYWRIGHT_FULL.test \ No newline at end of file diff --git a/Makefile b/Makefile index 1db2d8a..b023c01 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ ty: type-check ## Alias for type-check check: lint fmt type-check ## Run all checks except tests test: ## Run tests + @test -f tests/PLAYWRIGHT_FULL.test && uv run playwright install --with-deps chromium 2>/dev/null || true @uv run pytest ci: lint fmt type-check test ## Run everything diff --git a/pyproject.toml b/pyproject.toml index d38b928..54789ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,5 +98,4 @@ indent-style = "space" convention = "google" [tool.pytest.ini_options] -anyio_mode = "strict" -asyncio_default_fixture_loop_scope = "function" +addopts = "-p no:anyio" diff --git a/tests/PLAYWRIGHT_FULL.test.example b/tests/PLAYWRIGHT_FULL.test.example new file mode 100644 index 0000000..2b7e24b --- /dev/null +++ b/tests/PLAYWRIGHT_FULL.test.example @@ -0,0 +1,8 @@ +# Touch this file to enable full Playwright integration tests +# These tests require: +# - Playwright browsers installed (playwright install) +# - Authentication state (playwright/.auth/github_state.json) +# - Network access to GitHub +# +# These tests are skipped in CI by default to avoid flakiness +# remove ``.example` from the filename to enable them \ No newline at end of file diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..99c5b85 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,401 @@ +"""Tests for command parsing system. + +todo: test for auth checks to make sure they are handled properly +""" + +import os +from datetime import UTC, datetime + +import pytest + +from psrt_ghsa_bot.commands.parser import ( + AVAILABLE_COMMANDS, + Command, + get_help_text, + get_unknown_command_response, + is_valid_command, + parse_command, +) + + +@pytest.fixture +def bot_username() -> str: + """Get bot username from environment or use default.""" + return os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") + + +class TestCommandParsing: + """Test command parsing from comment text.""" + + def test_parse_simple_command(self) -> None: + """Test parsing a simple command without arguments.""" + result = parse_command( + "@psrt-ghsabot help", + "testuser", + "comment-1", + "psrt-ghsabot", + datetime(2024, 1, 1, 12, 0, 0), + ) + + assert result is not None + assert result.action == "help" + assert result.arguments == [] + assert result.author == "testuser" + assert result.comment_id == "comment-1" + assert result.timestamp == datetime(2024, 1, 1, 12, 0, 0) + + def test_parse_command_with_single_argument(self) -> None: + """Test parsing command with one argument.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-1234", + "maintainer", + "comment-2", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-1234"] + assert result.author == "maintainer" + + def test_parse_command_with_multiple_arguments(self) -> None: + """Test parsing command with multiple arguments.""" + result = parse_command( + "@psrt-ghsabot some-cmd arg1 arg2 arg3", + "user", + "comment-3", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "some-cmd" + assert result.arguments == ["arg1", "arg2", "arg3"] + + def test_parse_command_case_insensitive(self) -> None: + """Test that bot mention and command are case-insensitive.""" + variations = [ + "@PSRT-GHSABOT help", + "@Psrt-GhsaBot help", + "@psrt-ghsabot HELP", + "@psrt-ghsabot Help", + ] + + for text in variations: + result = parse_command(text, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "help" + + def test_parse_command_in_middle_of_text(self) -> None: + """Test parsing command embedded in larger comment.""" + comment = """ + I think we should handle this differently. + + @psrt-ghsabot reject CVE-2024-5678 + + Let me know if you agree. + """ + + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-5678"] + + def test_parse_command_with_extra_whitespace(self) -> None: + """Test parsing handles extra whitespace correctly.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-9999", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-9999"] + + def test_parse_command_with_alias(self) -> None: + """Test that command aliases work correctly.""" + result = parse_command( + "@psrt-ghsabot withdraw CVE-2024-1111", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-1111"] + + def test_parse_publish_command(self) -> None: + """Test parsing publish command.""" + result = parse_command( + "@psrt-ghsabot publish", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "publish" + assert result.arguments == [] + + def test_parse_publish_aliases(self) -> None: + """Test that publish aliases work correctly.""" + for alias in ["release", "complete"]: + result = parse_command( + f"@psrt-ghsabot {alias}", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "publish" + + def test_parse_no_command(self) -> None: + """Test that None is returned when no command is found.""" + result = parse_command( + "Just a regular comment without bot mention", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is None + + def test_parse_incomplete_mention(self) -> None: + """Test that incomplete mentions don't parse.""" + result = parse_command("@psrt", "user", "comment", "psrt-ghsabot") + assert result is None + + result = parse_command("psrt-ghsabot help", "user", "comment", "psrt-ghsabot") + assert result is None + + def test_parse_empty_comment(self) -> None: + """Test parsing empty or None comment.""" + assert parse_command("", "user", "comment", "psrt-ghsabot") is None + assert parse_command(None, "user", "comment", "psrt-ghsabot") is None + + def test_parse_command_default_timestamp(self) -> None: + """Test that timestamp defaults to current time.""" + before = datetime.now(tz=UTC) + result = parse_command("@psrt-ghsabot help", "user", "comment", "psrt-ghsabot") + after = datetime.now(tz=UTC) + + assert result is not None + assert before <= result.timestamp <= after + + def test_parse_command_custom_bot_username(self) -> None: + """Test parsing with custom bot username.""" + result = parse_command( + "@my-custom-bot reject CVE-2024-9999", + "user", + "comment", + "my-custom-bot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-9999"] + + def test_parse_command_username_with_special_chars(self) -> None: + """Test parsing bot username with regex special characters.""" + result = parse_command( + "@bot.test+dev reject CVE-2024-9999", + "user", + "comment", + "bot.test+dev", + ) + + assert result is not None + assert result.action == "reject" + + +class TestCommandValidation: + """Test command validation functions.""" + + def test_is_valid_command_recognized(self) -> None: + """Test that recognized commands are valid.""" + for cmd in AVAILABLE_COMMANDS: + assert is_valid_command(cmd) + + def test_is_valid_command_case_insensitive(self) -> None: + """Test validation is case-insensitive.""" + assert is_valid_command("HELP") + assert is_valid_command("Help") + assert is_valid_command("reject") + assert is_valid_command("REJECT") + + def test_is_valid_command_unrecognized(self) -> None: + """Test that unrecognized commands are invalid.""" + assert not is_valid_command("unknown") + assert not is_valid_command("foo") + assert not is_valid_command("delete-everything") + + def test_is_valid_command_publish(self) -> None: + """Test that publish command is recognized.""" + assert is_valid_command("publish") + assert is_valid_command("PUBLISH") + assert is_valid_command("Publish") + + +class TestHelpText: + """Test help text generation.""" + + def test_get_help_text_format(self) -> None: + """Test that help text is properly formatted.""" + help_text = get_help_text("psrt-ghsabot") + + assert "PSRT GHSA Bot Commands" in help_text + assert "Available commands:" in help_text + + for cmd_name, cmd_info in AVAILABLE_COMMANDS.items(): + assert cmd_name in help_text.lower() + assert cmd_info["description"] in help_text + assert f"@psrt-ghsabot {cmd_info['usage']}" in help_text + assert f"@psrt-ghsabot {cmd_info['example']}" in help_text + + def test_get_help_text_includes_all_commands(self) -> None: + """Test that all commands are documented in help.""" + help_text = get_help_text("psrt-ghsabot") + + for cmd_name in AVAILABLE_COMMANDS: + assert cmd_name in help_text.lower() + + def test_get_help_text_shows_aliases(self) -> None: + """Test that command aliases are shown in help.""" + help_text = get_help_text("psrt-ghsabot") + + assert "withdraw" in help_text + assert "request-cve" in help_text + assert "release" in help_text + assert "complete" in help_text + + def test_get_help_text_custom_bot_username(self) -> None: + """Test help text with custom bot username.""" + help_text = get_help_text("my-custom-bot") + + assert "@my-custom-bot help" in help_text + assert "@my-custom-bot reject" in help_text + assert "@my-custom-bot publish" in help_text + assert "@psrt-ghsabot" not in help_text + + +class TestUnknownCommandResponse: + """Test unknown command response generation.""" + + def test_get_unknown_command_response_includes_action(self) -> None: + """Test that response includes the unknown action.""" + response = get_unknown_command_response("foo") + + assert "foo" in response + assert "Unknown command" in response + + def test_get_unknown_command_response_lists_available(self) -> None: + """Test that response lists available commands.""" + response = get_unknown_command_response("invalid") + + for cmd in AVAILABLE_COMMANDS: + assert cmd in response.lower() + + def test_get_unknown_command_response_suggests_help(self, bot_username: str) -> None: + """Test that response suggests using help.""" + response = get_unknown_command_response("bad", bot_username) + + assert "help" in response.lower() + assert f"@{bot_username} help" in response + + +class TestCommandRepresentation: + """Test Command dataclass methods.""" + + def test_command_repr(self) -> None: + """Test Command __repr__ output.""" + cmd = Command( + action="reject", + arguments=["CVE-2024-1234"], + author="testuser", + comment_id="comment-1", + timestamp=datetime(2024, 1, 1), + ) + + repr_str = repr(cmd) + assert "reject" in repr_str + assert "CVE-2024-1234" in repr_str + assert "testuser" in repr_str + + def test_command_repr_no_arguments(self) -> None: + """Test Command __repr__ with no arguments.""" + cmd = Command( + action="help", + arguments=[], + author="user", + comment_id="comment", + timestamp=datetime.now(), + ) + + repr_str = repr(cmd) + assert "help" in repr_str + assert "(no args)" in repr_str + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_command_with_special_characters_in_args(self) -> None: + """Test parsing arguments with special characters.""" + result = parse_command( + "@psrt-ghsabot test arg-with-dash arg_with_underscore", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.arguments == ["arg-with-dash", "arg_with_underscore"] + + def test_multiple_bot_mentions(self) -> None: + """Test that only first mention is parsed.""" + comment = """ + @psrt-ghsabot help + + Actually, wait: + @psrt-ghsabot status + """ + + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "help" + + def test_command_at_start_of_line(self) -> None: + """Test command at beginning of comment.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-1234", + "user", + "comment", + "psrt-ghsabot", + ) + assert result is not None + + def test_command_at_end_of_line(self) -> None: + """Test command at end of comment.""" + result = parse_command( + "Here's my command: @psrt-ghsabot help", + "user", + "comment", + "psrt-ghsabot", + ) + assert result is not None + + def test_command_on_its_own_line(self) -> None: + """Test command on a line by itself.""" + comment = """ + Some context here. + + @psrt-ghsabot reject CVE-2024-1234 + + More context below. + """ + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "reject" diff --git a/tests/test_comments.py b/tests/test_comments.py index 6417f4b..3f0bf0e 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from collections.abc import Generator +PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() + @pytest.fixture def authenticated_client() -> Generator[GitHubPlaywrightClient]: @@ -111,8 +113,8 @@ def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient) - @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_ghsa_comment_dataclass_repr() -> None: """Test the GHSAComment repr method.""" @@ -133,8 +135,8 @@ def test_ghsa_comment_dataclass_repr() -> None: @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient) -> None: """Test retrieval from a GHSA with no comments.""" @@ -152,8 +154,8 @@ def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrig @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient) -> None: """Test that bot comments are properly detected.""" @@ -166,15 +168,14 @@ def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightC # Check if any bot comments are detected bot_comments = [c for c in comments if c.is_bot_comment] - [c for c in comments if not c.is_bot_comment] for bot_comment in bot_comments: assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient) -> None: """Test that comments are returned in chronological order.""" @@ -211,8 +212,8 @@ def test_ghsa_comment_dataclass_fields() -> None: @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: """Test error handling for invalid GHSA ID.""" @@ -279,8 +280,8 @@ def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, t @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient) -> None: """Test posting a comment and then reading it back.""" @@ -313,28 +314,28 @@ def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightC def test_post_comment_empty_body_error() -> None: """Test that posting an empty comment raises ValueError.""" - with GitHubPlaywrightClient(headless=True) as client: - with pytest.raises(ValueError, match="comment_body cannot be empty"): - post_ghsa_comment( - client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - comment_body="", - ) + client = GitHubPlaywrightClient(headless=True) + with pytest.raises(ValueError, match="comment_body cannot be empty"): + post_ghsa_comment( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body="", + ) def test_post_comment_whitespace_only_error() -> None: """Test that posting whitespace-only comment raises ValueError.""" - with GitHubPlaywrightClient(headless=True) as client: - with pytest.raises(ValueError, match="comment_body cannot be empty"): - post_ghsa_comment( - client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - comment_body=" \n\t ", - ) + client = GitHubPlaywrightClient(headless=True) + with pytest.raises(ValueError, match="comment_body cannot be empty"): + post_ghsa_comment( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=" \n\t ", + ) @pytest.mark.skip(reason="Manual test - posts to real GHSA") diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..bf89637 --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,242 @@ +"""Tests for command execution.""" + +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from psrt_ghsa_bot.commands import CommandResult, execute_command +from psrt_ghsa_bot.commands.parser import Command + + +@pytest.fixture +def mock_github(): + """Create mock GitHub client.""" + return Mock() + + +@pytest.fixture +def mock_playwright(): + """Create mock Playwright client.""" + mock = Mock() + mock.username = "test-bot" + return mock + + +@pytest.fixture +def sample_command(): + """Create sample command.""" + return Command( + action="help", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + +class TestCommandExecution: + """Test command execution.""" + + def test_execute_help_command(self, sample_command, mock_github, mock_playwright) -> None: + """Test executing help command.""" + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + sample_command, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert isinstance(result, CommandResult) + assert result.success + assert "PSRT GHSA Bot Commands" in result.message + assert "@test-bot help" in result.message + + def test_execute_status_command_stub(self, mock_github, mock_playwright) -> None: + """Test executing status command (stub).""" + cmd = Command( + action="status", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.parsed_data = Mock( + state="draft", + cve_id="CVE-2024-1234", + created_at="2024-01-01T00:00:00Z", + updated_at="2024-01-15T00:00:00Z", + ) + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "Advisory Status" in result.message + assert "GHSA-1234" in result.message + assert "draft" in result.message + + def test_execute_reject_command_with_cve(self, mock_github, mock_playwright) -> None: + """Test executing reject command with CVE ID.""" + cmd = Command( + action="reject", + arguments=["CVE-2024-1234"], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.parsed_data = Mock(cve_id="CVE-2024-1234") + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "CVE Rejected" in result.message + assert "CVE-2024-1234" in result.message + + def test_execute_reject_without_cve_fails(self, mock_github, mock_playwright) -> None: + """Test executing reject command without CVE ID fails.""" + cmd = Command( + action="reject", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Missing CVE ID" in result.message + + def test_execute_assign_cve_command(self, mock_github, mock_playwright) -> None: + """Test executing assign-cve command.""" + cmd = Command( + action="assign-cve", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.parsed_data = Mock(cve_id=None) + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + # Assign CVE requires CVE API credentials which won't be available in tests + # So we expect it to fail but still test it's calling the right handler + assert "assign" in result.message.lower() or "cve" in result.message.lower() + + def test_execute_publish_command(self, mock_github, mock_playwright) -> None: + """Test executing publish command.""" + cmd = Command( + action="publish", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "Publish Advisory" in result.message + + def test_execute_unknown_command(self, mock_github, mock_playwright) -> None: + """Test executing unknown command.""" + cmd = Command( + action="unknown-action", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Unknown command" in result.message + assert "unknown-action" in result.message + + def test_execute_unauthorized_user(self, sample_command, mock_github, mock_playwright) -> None: + """Test executing command as unauthorized user.""" + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=404) + mock_github.rest.security_advisories.get_repository_advisory.side_effect = Exception("Not found") + mock_github.rest.repos.get_collaborator_permission_level.side_effect = Exception("Not found") + + result = execute_command( + sample_command, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Unauthorized" in result.message + assert "testuser" in result.message diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py index 2b6254c..6173338 100644 --- a/tests/test_playwright_base.py +++ b/tests/test_playwright_base.py @@ -6,6 +6,8 @@ from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() + @pytest.fixture def client() -> GitHubPlaywrightClient: @@ -39,8 +41,8 @@ def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing auth state (PAT tokens don't work for web UI auth)", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None: """Test authentication using saved state from manual login.""" @@ -50,8 +52,8 @@ def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: """Test navigation to a GHSA page (requires authentication).""" @@ -66,8 +68,8 @@ def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: @pytest.mark.skipif( - not Path("playwright/.auth/github_state.json").exists(), - reason="Requires existing authentication state", + not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", ) def test_authentication_state_persistence(client: GitHubPlaywrightClient) -> None: """Test that authentication state is saved and can be reused.""" diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..0a3715b --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,207 @@ +"""Tests for state management.""" + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from psrt_ghsa_bot.state import BotState, GHSAState, StateManager + + +def test_ghsa_state_to_dict() -> None: + state = GHSAState( + last_comment_id="comment-123", + last_processed_at="2025-11-19T12:00:00Z", + processed_commands={"hash1", "hash2"}, + commands_processed_count=2, + ) + + data = state.to_dict() + + assert data["last_comment_id"] == "comment-123" + assert data["last_processed_at"] == "2025-11-19T12:00:00Z" + assert set(data["processed_commands"]) == {"hash1", "hash2"} + assert data["commands_processed_count"] == 2 + + +def test_ghsa_state_from_dict() -> None: + data = { + "last_comment_id": "comment-123", + "last_processed_at": "2025-11-19T12:00:00Z", + "processed_commands": ["hash1", "hash2"], + "commands_processed_count": 2, + } + + state = GHSAState.from_dict(data) + + assert state.last_comment_id == "comment-123" + assert state.last_processed_at == "2025-11-19T12:00:00Z" + assert state.processed_commands == {"hash1", "hash2"} + assert state.commands_processed_count == 2 + + +def test_bot_state_to_dict() -> None: + bot_state = BotState( + last_run="2025-11-19T12:00:00Z", + ghsas={ + "python/cpython/GHSA-xxxx": GHSAState( + last_comment_id="comment-123", + processed_commands={"hash1"}, + commands_processed_count=1, + ) + }, + ) + + data = bot_state.to_dict() + + assert data["last_run"] == "2025-11-19T12:00:00Z" + assert "python/cpython/GHSA-xxxx" in data["ghsas"] + assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_comment_id"] == "comment-123" + + +def test_bot_state_from_dict() -> None: + data = { + "last_run": "2025-11-19T12:00:00Z", + "ghsas": { + "python/cpython/GHSA-xxxx": { + "last_comment_id": "comment-123", + "last_processed_at": None, + "processed_commands": ["hash1"], + "commands_processed_count": 1, + } + }, + } + + bot_state = BotState.from_dict(data) + + assert bot_state.last_run == "2025-11-19T12:00:00Z" + assert "python/cpython/GHSA-xxxx" in bot_state.ghsas + assert bot_state.ghsas["python/cpython/GHSA-xxxx"].last_comment_id == "comment-123" + + +def test_state_manager_load_empty() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + state = manager.load() + + assert state.last_run is None + assert len(state.ghsas) == 0 + + +def test_state_manager_save_and_load() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + state = manager.load() + state.ghsas["python/cpython/GHSA-xxxx"] = GHSAState(last_comment_id="comment-123") + + manager.save() + + with Path.open(state_file) as f: + data = json.load(f) + + assert "python/cpython/GHSA-xxxx" in data["ghsas"] + assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_comment_id"] == "comment-123" + + +def test_state_manager_get_ghsa_state() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + + assert ghsa_state.last_comment_id is None + assert len(ghsa_state.processed_commands) == 0 + + +def test_state_manager_is_command_processed() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + is_processed = manager.is_command_processed( + "python/cpython/GHSA-xxxx", + "comment-123", + "@bot help", + "octocat", + ) + + assert not is_processed + + +def test_state_manager_mark_command_processed() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.mark_command_processed( + "python/cpython/GHSA-xxxx", + "comment-123", + "@bot help", + "octocat", + ) + + is_processed = manager.is_command_processed( + "python/cpython/GHSA-xxxx", + "comment-123", + "@bot help", + "octocat", + ) + + assert is_processed + + +def test_state_manager_command_hash_different_for_different_inputs() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.mark_command_processed( + "python/cpython/GHSA-xxxx", + "comment-123", + "@bot help", + "octocat", + ) + + is_processed_different_comment = manager.is_command_processed( + "python/cpython/GHSA-xxxx", + "comment-456", + "@bot help", + "octocat", + ) + assert not is_processed_different_comment + + is_processed_different_command = manager.is_command_processed( + "python/cpython/GHSA-xxxx", + "comment-123", + "@bot status", + "octocat", + ) + assert not is_processed_different_command + + is_processed_different_author = manager.is_command_processed( + "python/cpython/GHSA-xxxx", + "comment-123", + "@bot help", + "different-user", + ) + assert not is_processed_different_author + + +def test_state_manager_update_ghsa_state() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.update_ghsa_state( + "python/cpython/GHSA-xxxx", + last_comment_id="comment-999", + ) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + + assert ghsa_state.last_comment_id == "comment-999" + assert ghsa_state.last_processed_at is not None From 05283d882e05490304eb84254e17f2e6cbff5885 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 20:58:25 -0600 Subject: [PATCH 26/64] add some info to help people onboard --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/README.md b/README.md index cacc96f..47b2a75 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ # PSRT GHSA Bot Bot which adds the PSRT GitHub team (`python/psrt`) and CVE IDs to GitHub Security Advisories. + +## Moving Pieces + +### GitHub Actions + +- The cron bot runs off of [`.github/workflows/cron.yml`](.github/workflows/cron.yml) and runs at the top of each hour. + - Calls [src/psrt_ghsa_bot/app.py](src/psrt_ghsa_bot/app.py) + - Fetches open GHSAs from all installed orgs/repos + - Adds PSRT team to GHSAs without it + - Assigns CVE IDs to draft GHSAs without one +- The Playwright bot runs off of [`.github/workflows/playwright.yml`](.github/workflows/playwright.yml) and runs every 5 minutes. + - Calls [src/psrt_ghsa_bot/comment_processor.py](src/psrt_ghsa_bot/comment_processor.py) + - Reads GHSA comments via Playwright (no API available) + - Parses `@` commands, executes if authorized + - Posts responses, tracks state in `state.json` +- Health checks are done via [`.github/workflows/health-check.yml`](.github/workflows/health-check.yml) and runs every 15 minutes. + - It checks the status using the `gh` CLI and reports to Sentry if the bot is not healthy via Sentry cron monitors. + +### Why Playwright? + +The GHSA API is limited and has not really been developed in awhile. As such, it is missing a lot of features +like: +- Commenting on GHSAs +- Adding teams to GHSAs +- Assigning CVE IDs to GHSAs +- Removing temporary forks generated inside a GHSA that collaboraters use fro remediation +- No webhooks to respond to things.. so we do the GHA polling thing... + +That's why there is this weird split between the cron.yml and playwright.yml. As API things +are added, we can move more into app.py/cron.yml and remove the playwright stuff (gladly!) + +## Development + +Uses `uv` for dependency management and `pytest` for testing. + +### Setup + +Make sure you have `uv` installed athttps://docs.astral.sh/uv/getting-started/installation/ +Quickly, for Linux/macOS: +```shell +curl -LsSf https://astral.sh/uv/install.sh | sh +``` +or (not recommended): +```shell +pipx install uv +``` + +Afterwards, you can use `make` to run the commands in the [`Makefile`](Makefile). +- `make upgrade` - Upgrade all dependencies to the latest stable versions. + +Every time you run `uv run` or anything it automatically installs/syncs the dependencies +and it's near-instant so there is no `make install` or anything. + +### Tests + +Only unique things here are `PLAYWRIGHT_FULL.test`, which can can see more about in [`PLAYWRIGHT_FULL.test.example`](tests/PLAYWRIGHT_FULL.test.example). +This just tells the [`Makefile`](Makefile) target `make test` to run `playwright install` before running the tests +and then enables some of the skipped tests. These tests assume a test organization and all the setup behind that +because it is an integration test and will comment on and read a GHSA advisory. + +### Scripts + +There is a [`scripts/`](scripts/) directory with some local dev scripts, namely one that will +set up an organization with all the things needed to develop (TODO: it doesn't actually do anything yet.) + +The idea behind the bootstrap_org.py is that it will: +- Take your org \ No newline at end of file From 03bac839b72c75f65eb86344f821071f4d00a917 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 21:07:02 -0600 Subject: [PATCH 27/64] add words --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 47b2a75..7ded35c 100644 --- a/README.md +++ b/README.md @@ -67,4 +67,5 @@ There is a [`scripts/`](scripts/) directory with some local dev scripts, namely set up an organization with all the things needed to develop (TODO: it doesn't actually do anything yet.) The idea behind the bootstrap_org.py is that it will: -- Take your org \ No newline at end of file +- Take your test org, set up a `psrt` (or whatever) team, create a repo with some GHSA, then comment, read the comments, etc. +This helps more from the integration testing side of things without doing it all in some public, busy repo like `python/CPython` :) \ No newline at end of file From a6c498ea246f99a2f9ece92a34fd7c6af90a2cc4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 21:07:16 -0600 Subject: [PATCH 28/64] fix spacing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ded35c..e39b32c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Uses `uv` for dependency management and `pytest` for testing. ### Setup -Make sure you have `uv` installed athttps://docs.astral.sh/uv/getting-started/installation/ +Make sure you have `uv` installed at https://docs.astral.sh/uv/getting-started/installation/ Quickly, for Linux/macOS: ```shell curl -LsSf https://astral.sh/uv/install.sh | sh From 2c5d40f02521c7f56fd0b42545703bdaa60697b1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 21:07:27 -0600 Subject: [PATCH 29/64] spelling... --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e39b32c..c607155 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ like: - Commenting on GHSAs - Adding teams to GHSAs - Assigning CVE IDs to GHSAs -- Removing temporary forks generated inside a GHSA that collaboraters use fro remediation +- Removing temporary forks generated inside a GHSA that collaborators use fro remediation - No webhooks to respond to things.. so we do the GHA polling thing... That's why there is this weird split between the cron.yml and playwright.yml. As API things From a772d265585e1550c352062a1ede9378e632eace Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 21:13:58 -0600 Subject: [PATCH 30/64] we have cron-run that is more clear --- Makefile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Makefile b/Makefile index b023c01..30a29ae 100644 --- a/Makefile +++ b/Makefile @@ -30,9 +30,6 @@ test: ## Run tests ci: lint fmt type-check test ## Run everything -app: ## Run the app - @uv run python app.py - ### --- Bot Things ### These all reequire .env file with the vars set based on .env.example! cron-run: ## Run the cron bot (app.py) From f82009098424816355065c5e7ae0d3af9b1284ce Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 21:19:48 -0600 Subject: [PATCH 31/64] more docs for weirdness --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index c607155..afa095d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ like: That's why there is this weird split between the cron.yml and playwright.yml. As API things are added, we can move more into app.py/cron.yml and remove the playwright stuff (gladly!) +### Notes + +One things about the Playwright deal is that it uses the GitHub installation from the API bits +to generate a list of repos (~[comment_processor.py:130-140](src/psrt_ghsa_bot/comment_processor.py#L130-L140)). +This is sort've praying that the GitHub app installation and the GitHub user that you set up +has the same permissions. There's not really a way I can see to ensure same-permissions so this is +a best effort and it would be caught quickly in local testing at least (I hope.) + ## Development Uses `uv` for dependency management and `pytest` for testing. From f568faebfe661ca7aad860392c1d77c112309a6a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 21:22:10 -0600 Subject: [PATCH 32/64] EVEN MORE docs on clarifying weirdness and requirements --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index afa095d..1ccbfa2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,11 @@ like: That's why there is this weird split between the cron.yml and playwright.yml. As API things are added, we can move more into app.py/cron.yml and remove the playwright stuff (gladly!) -### Notes +## Notes + +The GitHub app (API-related activities) **MUST** be installed in all GitHub organizations +you want scanned. The GitHub user (`GH_BOT_USERNAME`, used by Playwright) **MUST** have access to the repos that +you want to interact with. One things about the Playwright deal is that it uses the GitHub installation from the API bits to generate a list of repos (~[comment_processor.py:130-140](src/psrt_ghsa_bot/comment_processor.py#L130-L140)). @@ -40,6 +44,7 @@ This is sort've praying that the GitHub app installation and the GitHub user tha has the same permissions. There's not really a way I can see to ensure same-permissions so this is a best effort and it would be caught quickly in local testing at least (I hope.) + ## Development Uses `uv` for dependency management and `pytest` for testing. From 8714967002e782881e4f46e3a9f666e146e7fca5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:22:07 -0600 Subject: [PATCH 33/64] update makefile section printouts --- Makefile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 30a29ae..1332326 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ help: ## Display this help text for Makefile @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) +##@ Development + upgrade: ## Upgrade all dependencies to the latest stable versions @uv lock --upgrade @echo "=> Dependencies Updated" @@ -30,8 +32,9 @@ test: ## Run tests ci: lint fmt type-check test ## Run everything -### --- Bot Things -### These all reequire .env file with the vars set based on .env.example! +##@ Live Bot Commands +### These all require .env file with the vars set based on .env.example! + cron-run: ## Run the cron bot (app.py) @uv run python -m psrt_ghsa_bot.app From f836ebd56fa86fa98f9c8985afc17fa9b57a5ba7 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:24:13 -0600 Subject: [PATCH 34/64] address comments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ccbfa2..31c0407 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Bot which adds the PSRT GitHub team (`python/psrt`) and CVE IDs to GitHub Security Advisories. -## Moving Pieces +## Architecture ### GitHub Actions From d20a30952a113674cccc6d9aaa3a8a0bf7d02191 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:24:49 -0600 Subject: [PATCH 35/64] address comments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 31c0407..08b2860 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ like: That's why there is this weird split between the cron.yml and playwright.yml. As API things are added, we can move more into app.py/cron.yml and remove the playwright stuff (gladly!) -## Notes +## Installation The GitHub app (API-related activities) **MUST** be installed in all GitHub organizations you want scanned. The GitHub user (`GH_BOT_USERNAME`, used by Playwright) **MUST** have access to the repos that From dcb77e2e02154ce1dae0eef019fd38aeeff54ec6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:26:44 -0600 Subject: [PATCH 36/64] address comments --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 08b2860..4be658d 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,10 @@ The GitHub app (API-related activities) **MUST** be installed in all GitHub orga you want scanned. The GitHub user (`GH_BOT_USERNAME`, used by Playwright) **MUST** have access to the repos that you want to interact with. -One things about the Playwright deal is that it uses the GitHub installation from the API bits -to generate a list of repos (~[comment_processor.py:130-140](src/psrt_ghsa_bot/comment_processor.py#L130-L140)). -This is sort've praying that the GitHub app installation and the GitHub user that you set up -has the same permissions. There's not really a way I can see to ensure same-permissions so this is -a best effort and it would be caught quickly in local testing at least (I hope.) +**Important:** The GitHub App installation and GitHub user require the same permissions on repositories. +The Playwright bot uses the GitHub App installation to generate a list of repos +(~[comment_processor.py:130-140](src/psrt_ghsa_bot/comment_processor.py#L130-L140)), so any permission +mismatch will cause failures ## Development From 263c50e1c5f2cc76ab5d729f6e3a5e728a9fd945 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:28:43 -0600 Subject: [PATCH 37/64] simplify, remove unused call on enumerate --- src/psrt_ghsa_bot/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/app.py b/src/psrt_ghsa_bot/app.py index c89c8aa..d90eb57 100644 --- a/src/psrt_ghsa_bot/app.py +++ b/src/psrt_ghsa_bot/app.py @@ -107,7 +107,7 @@ def main() -> None: installations = github.rest.paginate( github.rest.apps.list_installations, ) - for _installation_count, installation_data in enumerate(installations, start=1): + for installation_data in installations: installation_github = github.with_auth( github.auth.as_installation(installation_data.id), ) From 182ddee0a7815dbe8ab47e926678fe047725f99f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:29:58 -0600 Subject: [PATCH 38/64] enable health check --- src/psrt_ghsa_bot/health_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index 98e80a8..d505999 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -22,7 +22,7 @@ def check_workflow_health() -> None: workflows_to_check = [ {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": "psrt-ghsa-cron"}, - # {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, # noqa: ERA001, E501 + {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, # noqa: ERA001, E501 ] all_healthy = True From 1a72dbd9ea4b65bf3062f35a7894dcc731ce9f49 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:32:10 -0600 Subject: [PATCH 39/64] remove unused noqa --- src/psrt_ghsa_bot/health_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index d505999..105bc36 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -22,7 +22,7 @@ def check_workflow_health() -> None: workflows_to_check = [ {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": "psrt-ghsa-cron"}, - {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, # noqa: ERA001, E501 + {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, ] all_healthy = True From a6df7107ebb933c1d8eeb573140df317a6a52835 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:37:24 -0600 Subject: [PATCH 40/64] configify slugs, change workflows --- src/psrt_ghsa_bot/config.py | 7 +++++++ src/psrt_ghsa_bot/health_check.py | 23 ++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 src/psrt_ghsa_bot/config.py diff --git a/src/psrt_ghsa_bot/config.py b/src/psrt_ghsa_bot/config.py new file mode 100644 index 0000000..f4059b3 --- /dev/null +++ b/src/psrt_ghsa_bot/config.py @@ -0,0 +1,7 @@ +"""Common configuration for the PSRT GHSA Bot.""" + +from typing import Final + +MONITOR_SLUG_HEALTH: Final[str] = "psrt-health-monitor" +MONITOR_SLUG_GHSA: Final[str] = "psrt-ghsa-cron" +MONITOR_SLUG_PLAYWRIGHT: Final[str] = "psrt-playwright-cron" diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index 105bc36..7fefc36 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -10,24 +10,29 @@ import sys from psrt_ghsa_bot._monitoring import capture_checkin, init_sentry, report_workflow_failure +from psrt_ghsa_bot.config import ( + MONITOR_SLUG_GHSA, + MONITOR_SLUG_HEALTH, + MONITOR_SLUG_PLAYWRIGHT, +) logger = logging.getLogger(__name__) +WORKFLOWS_TO_CHECK = [ + {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": MONITOR_SLUG_GHSA}, + {"name": "PSRT Playwright Bot", "file": "playwright.yml", "monitor_slug": MONITOR_SLUG_PLAYWRIGHT}, +] + def check_workflow_health() -> None: """Check the health of configured workflows and report to Sentry.""" init_sentry() - capture_checkin("psrt-health-monitor", "in_progress") - - workflows_to_check = [ - {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": "psrt-ghsa-cron"}, - {"name": "PSRT Playright Bot", "file": "playwright.yml", "monitor_slug": "psrt-playwright-cron"}, - ] + capture_checkin(MONITOR_SLUG_HEALTH, "in_progress") all_healthy = True - for workflow in workflows_to_check: + for workflow in WORKFLOWS_TO_CHECK: logger.info("Checking workflow: %s", workflow["name"]) result = subprocess.run( # noqa: S603 @@ -80,10 +85,10 @@ def check_workflow_health() -> None: all_healthy = False if all_healthy: - capture_checkin("psrt-health-monitor", "ok") + capture_checkin(MONITOR_SLUG_HEALTH, "ok") logger.info("All workflows healthy") else: - capture_checkin("psrt-health-monitor", "error") + capture_checkin(MONITOR_SLUG_HEALTH, "error") logger.error("Some workflows are unhealthy") sys.exit(1) From 2ea402a661b36c9cca68cc3954a3d3a091c8d9ad Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:39:58 -0600 Subject: [PATCH 41/64] make bootstrap org private --- scripts/bootstrap_org.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 scripts/bootstrap_org.py diff --git a/scripts/bootstrap_org.py b/scripts/bootstrap_org.py deleted file mode 100644 index ba2fb24..0000000 --- a/scripts/bootstrap_org.py +++ /dev/null @@ -1,8 +0,0 @@ -"""TODO: bootstrapping script for setting up a 'psrt' team if not existing. - -also other things tat might need to be done to get a test or ready for local dev -with the bot - -also maybe we should make the psrt team thing configurable because that doesnt -scale to other orgs that might want to use this bot -""" From 4c571334933a5ccdd0d273a5704adbb8dd302ae7 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:44:02 -0600 Subject: [PATCH 42/64] move commands into another branch --- src/psrt_ghsa_bot/commands/__init__.py | 14 - src/psrt_ghsa_bot/commands/authorization.py | 200 ---------- src/psrt_ghsa_bot/commands/executor.py | 309 --------------- src/psrt_ghsa_bot/commands/parser.py | 227 ----------- src/psrt_ghsa_bot/comment_processor.py | 68 +--- tests/test_commands.py | 401 -------------------- tests/test_executor.py | 242 ------------ 7 files changed, 8 insertions(+), 1453 deletions(-) delete mode 100644 src/psrt_ghsa_bot/commands/__init__.py delete mode 100644 src/psrt_ghsa_bot/commands/authorization.py delete mode 100644 src/psrt_ghsa_bot/commands/executor.py delete mode 100644 src/psrt_ghsa_bot/commands/parser.py delete mode 100644 tests/test_commands.py delete mode 100644 tests/test_executor.py diff --git a/src/psrt_ghsa_bot/commands/__init__.py b/src/psrt_ghsa_bot/commands/__init__.py deleted file mode 100644 index ef83000..0000000 --- a/src/psrt_ghsa_bot/commands/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Command processing system for PSRT GHSA Bot.""" - -from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized -from psrt_ghsa_bot.commands.executor import CommandResult, execute_command -from psrt_ghsa_bot.commands.parser import Command, parse_command - -__all__ = [ - "AuthorizationResult", - "Command", - "CommandResult", - "execute_command", - "is_authorized", - "parse_command", -] diff --git a/src/psrt_ghsa_bot/commands/authorization.py b/src/psrt_ghsa_bot/commands/authorization.py deleted file mode 100644 index d1fba4d..0000000 --- a/src/psrt_ghsa_bot/commands/authorization.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Authorization checks for command execution. - -Determines if a user is authorized to execute bot commands based on: -- GHSA collaborator status (?) -- Repository admin status -- python/psrt team membership TODO: make this configurable, and allow list of org/teams? - like, what if we want PSRT to be able to responds across all PSF repos? (psf, python, pycon, pypi?) - or maybe we just say "team is $TEAM, and this bot works in $ORG as long as you are member of - that $TEAM" so we leave the user mgmt to the org admins. yeah.. probably that. -""" - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from githubkit import GitHub - -from http import HTTPStatus - - -@dataclass -class AuthorizationResult: - """Result of authorization check.""" - - authorized: bool - """Whether the user is authorized""" - reason: str - """Human-readable reason for the decision""" - - -def is_authorized( - github: GitHub, - username: str, - owner: str, - repo: str, - ghsa_id: str, -) -> AuthorizationResult: - """Check if user is authorized to execute commands. - - Authorization hierarchy: - 1. Members of python/psrt team - 2. GHSA collaborators - 3. Repository admins - - Args: - github: Authenticated GitHub client - username: GitHub username to check - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - AuthorizationResult indicating if user is authorized and a rason - """ - if _is_psrt_team_member(github, username): - return AuthorizationResult( - authorized=True, - reason=f"User {username} is a member of python/psrt team", - ) - - if _is_ghsa_collaborator(github, username, owner, repo, ghsa_id): - return AuthorizationResult( - authorized=True, - reason=f"User {username} is a collaborator on this advisory", - ) - - if _is_repo_admin(github, username, owner, repo): - return AuthorizationResult( - authorized=True, - reason=f"User {username} is an admin of {owner}/{repo}", - ) - - return AuthorizationResult( - authorized=False, - reason=f"User {username} is not authorized to execute commands", - ) - - -def _is_psrt_team_member(github: GitHub, username: str) -> bool: - """Check if user is member of python/psrt team. - - Args: - github: Authenticated GitHub client - username: GitHub username to check - - Returns: - True if user is a member of the python/psrt team - """ - try: - response = github.rest.teams.get_member_in_org( - org="python", - team_slug="psrt", - username=username, - ) - except Exception: - return False - else: - return response.status_code == HTTPStatus.NO_CONTENT - - -def _is_ghsa_collaborator( - github: GitHub, - username: str, - owner: str, - repo: str, - ghsa_id: str, -) -> bool: - """Check if user is collaborator on the GHSA. - - Args: - github: Authenticated GitHub client - username: GitHub username to check - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - True if user is a collaborator on the advisory - """ - try: - advisory = github.rest.security_advisories.get_repository_advisory( - owner=owner, - repo=repo, - ghsa_id=ghsa_id, - ) - - if not advisory.parsed_data: - return False - - collaborators = advisory.parsed_data.collaborating_users or [] - teams = advisory.parsed_data.collaborating_teams or [] - - for collaborator in collaborators: - if collaborator.login and collaborator.login.lower() == username.lower(): - return True - - return any(team.slug and _is_team_member(github, owner, team.slug, username) for team in teams) - except Exception: - return False - - -def _is_team_member( - github: GitHub, - org: str, - team_slug: str, - username: str, -) -> bool: - """Check if user is member of a specific team. - - Args: - github: Authenticated GitHub client - org: Organization name - team_slug: Team slug - username: GitHub username to check - - Returns: - True if user is a member of the team - """ - try: - response = github.rest.teams.get_member_in_org( - org=org, - team_slug=team_slug, - username=username, - ) - except Exception: - return False - else: - return response.status_code == HTTPStatus.NO_CONTENT - - -def _is_repo_admin( - github: GitHub, - username: str, - owner: str, - repo: str, -) -> bool: - """Check if user has admin permissions on repository. - - Args: - github: Authenticated GitHub client - username: GitHub username to check - owner: Repository owner - repo: Repository name - - Returns: - True if user is a repository admin - """ - try: - response = github.rest.repos.get_collaborator_permission_level( - owner=owner, - repo=repo, - username=username, - ) - - if not response.parsed_data or not response.parsed_data.permission: - return False - except Exception: - return False - else: - return response.parsed_data.permission == "admin" diff --git a/src/psrt_ghsa_bot/commands/executor.py b/src/psrt_ghsa_bot/commands/executor.py deleted file mode 100644 index 135df3a..0000000 --- a/src/psrt_ghsa_bot/commands/executor.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Command execution engine for PSRT GHSA Bot. - -TODO: Maybe we should look into easily extensiblke commands - like how discord.py or others do it so it can autodiscover - cmds and register them and keep this file just for the - executor, auth, results, and parser... -""" - -import os -from dataclasses import dataclass -from datetime import datetime -from typing import TYPE_CHECKING - -from cvelib.cve_api import CveApi - -from psrt_ghsa_bot.app import reserve_one_cve -from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized -from psrt_ghsa_bot.commands.parser import Command, get_help_text, get_unknown_command_response - -if TYPE_CHECKING: - from githubkit import GitHub - - from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient - - -@dataclass -class CommandResult: - """Result of command execution.""" - - success: bool - """Whether the command executed successfully""" - message: str - """Response message to post as comment""" - error: Exception | None = None - """Exception if command failed""" - - -def execute_command( - cmd: Command, - github: GitHub, - playwright_client: GitHubPlaywrightClient, - owner: str, - repo: str, - ghsa_id: str, -) -> CommandResult: - """Execute a parsed command. - - Example: "@ assign-cve" - - These are based on src/psrt_ghsa_bot/commands/parser.py:AVAILABLE_COMMANDS - and do an auth check before trying. - - Args: - cmd: Parsed command to execute - github: Authenticated GitHub API client - playwright_client: Playwright client for UI automation - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - CommandResult with success status and response message - """ - # TODO: i dont like this.. it will just grow and grow... - auth_result = is_authorized(github, cmd.author, owner, repo, ghsa_id) - - if not auth_result.authorized: - return _unauthorized_result(cmd, auth_result) - - if cmd.action == "help": - return _handle_help(playwright_client) - - if cmd.action == "status": - return _handle_status(cmd, github, owner, repo, ghsa_id) - - if cmd.action == "reject": - return _handle_reject(cmd, github, owner, repo, ghsa_id) - - if cmd.action == "assign-cve": - return _handle_assign_cve(cmd, github, owner, repo, ghsa_id) - - if cmd.action == "publish": - return _handle_publish(cmd, github, owner, repo, ghsa_id) - - return CommandResult( - success=False, - message=get_unknown_command_response(cmd.action, playwright_client.username), - ) - - -def _unauthorized_result(cmd: Command, auth_result: AuthorizationResult) -> CommandResult: - """Generate result for unauthorized command attempt. - - Args: - cmd: The command that was attempted - auth_result: Authorization check result - - Returns: - CommandResult with unauthorized message - """ - message = ( - f"❌ **Unauthorized**\n\n" - f"@{cmd.author}, you are not authorized to execute bot commands.\n\n" - f"**Reason:** {auth_result.reason}\n\n" - f"Only members of the `python/psrt` team and advisory collaborators can use bot commands." - ) - - return CommandResult(success=False, message=message) - - -def _handle_help(playwright_client: GitHubPlaywrightClient) -> CommandResult: - """Handle help command. - - Args: - playwright_client: Playwright client (for bot username) - - Returns: - CommandResult with help text - """ - help_text = get_help_text(playwright_client.username) - return CommandResult(success=True, message=help_text) - - -def _handle_status(_cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: - """Handle status command. - - Args: - cmd: Parsed command - github: GitHub API client - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - CommandResult with status information - """ - try: - advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) - - state = advisory.parsed_data.state - cve_id = advisory.parsed_data.cve_id or "None assigned" - created_at = advisory.parsed_data.created_at - updated_at = advisory.parsed_data.updated_at - created = datetime.fromisoformat(created_at) - days_old = (datetime.now(created.tzinfo) - created).days - - message = ( - f"📊 **Advisory Status**\n\n" - f"**Repository:** {owner}/{repo}\n" - f"**Advisory:** {ghsa_id}\n" - f"**State:** {state}\n" - f"**CVE ID:** {cve_id}\n" - f"**Created:** {days_old} days ago\n" - f"**Last Updated:** {updated_at}" - ) - - return CommandResult(success=True, message=message) - - except Exception as e: - return CommandResult( - success=False, - message=f"❌ **Error:** Failed to get advisory status: {e!s}", - error=e, - ) - - -def _handle_reject(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: - """Handle CVE rejection command. - - Args: - cmd: Parsed command - github: GitHub API client - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - CommandResult with rejection confirmation - """ - if not cmd.arguments: - return CommandResult( - success=False, - message="❌ **Error:** Missing CVE ID\n\nUsage: `reject `\n\nExample: `reject CVE-2024-1234`", - ) - - cve_id = cmd.arguments[0] - - try: - advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) - - current_cve = advisory.parsed_data.cve_id - - if current_cve is None: - return CommandResult( - success=False, - message=f"❌ **Error:** Advisory {ghsa_id} has no CVE ID assigned.", - ) - - if current_cve != cve_id: - return CommandResult( - success=False, - message=( - f"❌ **Error:** CVE ID mismatch\n\n" - f"Advisory {ghsa_id} is associated with {current_cve}, not {cve_id}." - ), - ) - - github.rest.security_advisories.update_repository_advisory( - owner=owner, - repo=repo, - ghsa_id=ghsa_id, - data={"cve_id": None}, - ) - - message = ( - f"đŸšĢ **CVE Rejected**\n\n" - f"**Advisory:** {ghsa_id}\n" - f"**CVE ID:** {cve_id}\n\n" - f"The CVE ID has been removed from this advisory.\n\n" - f"_Note: This does not withdraw the CVE from the CVE system. " - f"CVE rejection via API requires additional CVE API integration._" - ) - - return CommandResult(success=True, message=message) - - except Exception as e: - return CommandResult( - success=False, - message=f"❌ **Error:** Failed to reject CVE: {e!s}", - error=e, - ) - - -def _handle_assign_cve(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: - """Handle CVE assignment command. - - Args: - cmd: Parsed command - github: GitHub API client - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - CommandResult with assignment confirmation - """ - try: - advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) - current_cve = advisory.parsed_data.cve_id - - if current_cve is not None: - return CommandResult( - success=False, - message=( - f"**CVE Already Assigned**\n\n" - f"Advisory {ghsa_id} already has CVE ID {current_cve} assigned.\n\n" - f"Use `status` to view advisory details or `reject {current_cve}` to remove it." - ), - ) - - cve_api = CveApi( - org="PSF", - username=os.environ["CVE_USERNAME"], - api_key=os.environ["CVE_API_KEY"], - env=os.environ.get("CVE_ENV", "prod"), - ) - - cve_id = reserve_one_cve(cve_api) - - github.rest.security_advisories.update_repository_advisory( - owner=owner, - repo=repo, - ghsa_id=ghsa_id, - data={"cve_id": cve_id}, - ) - - message = ( - f"🔖 **CVE Assigned**\n\n" - f"**Advisory:** {ghsa_id}\n" - f"**CVE ID:** {cve_id}\n\n" - f"A new CVE ID has been reserved and associated with this advisory." - ) - - return CommandResult(success=True, message=message) - - except Exception as e: - return CommandResult( - success=False, - message=f"❌ **Error:** Failed to assign CVE: {e!s}", - error=e, - ) - - -def _handle_publish(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: - """Handle advisory publication command. - - Args: - cmd: Parsed command - github: GitHub API client - owner: Repository owner - repo: Repository name - ghsa_id: GHSA identifier - - Returns: - CommandResult with publication confirmation - """ - message = "đŸ“ĸ **Publish Advisory** (Stub)\n\nbut for now I am just a stub cmd :)" - - return CommandResult(success=True, message=message) diff --git a/src/psrt_ghsa_bot/commands/parser.py b/src/psrt_ghsa_bot/commands/parser.py deleted file mode 100644 index 5378ad2..0000000 --- a/src/psrt_ghsa_bot/commands/parser.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Command parser for extracting bot commands from GHSA comments.""" - -import os -import re -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import TypedDict - - -class CommandInfo(TypedDict, total=False): - """Type definition for command metadata because type checke rhates me.""" - - description: str - usage: str - example: str - aliases: list[str] - - -@dataclass -class Command: - """Represents a parsed command from a GHSA comment.""" - - action: str - """The command action (e.g., 'help', 'reject', 'assign-cve')""" - arguments: list[str] - """List of arguments provided to the command""" - author: str - """GitHub username who issued the command""" - comment_id: str - """ID of the comment containing the command""" - timestamp: datetime - """When the command was issued""" - - def __repr__(self) -> str: - """String repr for debugs.""" - args_str = " ".join(self.arguments) if self.arguments else "(no args)" - return f"Command({self.action} {args_str} by {self.author})" - - -AVAILABLE_COMMANDS: dict[str, CommandInfo] = { - "help": { - "description": "Show this help message with all available commands", - "usage": "help", - "example": "help", - }, - "reject": { - "description": "Reject/withdraw a CVE ID for this advisory", - "usage": "reject ", - "example": "reject CVE-2024-1234", - "aliases": ["withdraw"], - }, - "assign-cve": { - "description": "Request CVE ID assignment for this advisory", - "usage": "assign-cve", - "example": "assign-cve", - "aliases": ["request-cve"], - }, - "status": { - "description": "Show current status of this advisory and associated CVE", - "usage": "status", - "example": "status", - }, - "publish": { - "description": "Publish this advisory and associated CVE ID", - "usage": "publish", - "example": "publish", - "aliases": ["release", "complete"], - }, -} - -COMMAND_ALIASES = { - "withdraw": "reject", - "request-cve": "assign-cve", - "release": "publish", - "complete": "publish", -} - - -def _build_command_pattern(bot_username: str) -> re.Pattern[str]: - """Build regex pattern for matching bot commands. - - Args: - bot_username: The bot's GitHub username.. gotten from env var. - - Returns: - Compiled regex pattern that matches @ [args] - """ - escaped_username = re.escape(bot_username) - return re.compile( - rf"@{escaped_username}\s+(\S+)(?:\s+(.+))?", - re.IGNORECASE | re.MULTILINE, - ) - - -def parse_command( - comment_body: str | None, - author: str, - comment_id: str, - bot_username: str, - timestamp: datetime | None = None, -) -> Command | None: - """Parse a command from a comment body. - - Looks for pattern: @ [arguments...] - - Args: - comment_body: The full text of the comment - author: GitHub username who wrote the comment - comment_id: Unique identifier for the comment - bot_username: GitHub username of the bot to look for - timestamp: When the comment was created (defaults to now) - - Returns: - Parsed Command object, or None if no valid command found - - Example: - >>> parse_command( - ... "@ reject CVE-2024-1234", - ... "JacobCoffee", - ... "comment-123", - ... "" - ... ) - Command(reject CVE-2024-1234 by JacobCoffee) - """ - if not comment_body: - return None - - pattern = _build_command_pattern(bot_username) - match = pattern.search(comment_body) - if not match: - return None - - action = match.group(1).lower() - action = COMMAND_ALIASES.get(action, action) - - arguments_str = match.group(2) - arguments = arguments_str.split() if arguments_str else [] - - if timestamp is None: - timestamp = datetime.now(tz=UTC) - - return Command( - action=action, - arguments=arguments, - author=author, - comment_id=comment_id, - timestamp=timestamp, - ) - - -def is_valid_command(action: str) -> bool: - """Check if an action is a recognized command. - - Args: - action: The command action to validate - - Returns: - True if the action is recognized, False otherwise - """ - return action.lower() in AVAILABLE_COMMANDS - - -def get_help_text(bot_username: str | None = None) -> str: - """Generate help text listing all available commands. - - Args: - bot_username: The bot's GitHub username to use in examples. - Defaults to GH_BOT_USERNAME environment variable. - - Returns: - Formatted markdown help text - """ - lines = [ - "# PSRT GHSA Bot Commands", - "", - "Available commands:", - "", - ] - - for cmd_info in AVAILABLE_COMMANDS.values(): - usage = f"@{bot_username} {cmd_info['usage']}" - example = f"@{bot_username} {cmd_info['example']}" - - lines.append(f"### `{usage}`") - lines.append(cmd_info["description"]) - lines.append(f"**Example:** `{example}`") - - if "aliases" in cmd_info: - aliases = ", ".join(f"`{alias}`" for alias in cmd_info["aliases"]) - lines.append(f"**Aliases:** {aliases}") - - lines.append("") - - lines.extend( - [ - "--", - "", - f"_To use a command, mention `@{bot_username}` followed by the command name and any required arguments._", - "", - "_Only members of the `python/psrt` team and advisory collaborators can execute commands._", - ] - ) - - return "\n".join(lines) - - -def get_unknown_command_response(action: str, bot_username: str | None = None) -> str: - """Generate response message for unknown commands. - - Args: - action: The unrecognized command action - bot_username: The bot's GitHub username to use in help message. - Defaults to GH_BOT_USERNAME environment variable. - - Returns: - Formatted error message with help text - """ - if bot_username is None: - bot_username = os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") - - available = ", ".join(f"`{cmd}`" for cmd in AVAILABLE_COMMANDS) - - return ( - f"❌ Unknown command: `{action}`\n\n" - f"Available commands: {available}\n\n" - f"Use `@{bot_username} help` for detailed usage information." - ) diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py index 9bde4a5..bae4453 100644 --- a/src/psrt_ghsa_bot/comment_processor.py +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -1,7 +1,6 @@ """Comment processing service for PSRT GHSA Bot.""" import base64 -import contextlib import logging import os from dataclasses import dataclass @@ -10,9 +9,7 @@ from githubkit import AppAuthStrategy, GitHub from psrt_ghsa_bot.app import get_repository_advisories -from psrt_ghsa_bot.commands.executor import execute_command -from psrt_ghsa_bot.commands.parser import parse_command -from psrt_ghsa_bot.polyfills.comments import get_ghsa_comments, post_ghsa_comment +from psrt_ghsa_bot.polyfills.comments import get_ghsa_comments from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient from psrt_ghsa_bot.state import StateManager @@ -27,35 +24,26 @@ class CommentProcessingStats: ghsas_checked: int = 0 comments_found: int = 0 - commands_found: int = 0 - commands_executed: int = 0 - commands_skipped: int = 0 errors: int = 0 def process_ghsa_comments( - github: GitHub, playwright_client: GitHubPlaywrightClient, - state_manager: StateManager, owner: str, repo: str, ghsa_id: str, ) -> int: - """Process comments for a single GHSA. + """Read comments for a single GHSA. Args: - github: Authenticated GitHub API client playwright_client: Playwright client for UI automation - state_manager: State manager for tracking processed commands owner: Repository owner repo: Repository name ghsa_id: GHSA identifier Returns: - Number of commands executed + Number of comments found """ - ghsa_key = f"{owner}/{repo}/{ghsa_id}" - try: comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id) except PermissionError as e: @@ -69,46 +57,8 @@ def process_ghsa_comments( logger.debug("No comments found on %s", ghsa_id) return 0 - logger.info("Processing %d comments on %s", len(comments), ghsa_id) - commands_executed = 0 - for comment in comments: - comment_id = comment.id - author = comment.author - body = comment.body - - if author == playwright_client.username: - logger.debug("Skipping bot's own comment: %s", comment_id) - continue - - cmd = parse_command(body, author, comment_id, playwright_client.username, comment.created_at) - - if cmd is None: - logger.debug("No command in comment from @%s", author) - continue - - if state_manager.is_command_processed(ghsa_key, comment_id, body, author): - logger.debug("Command already processed: %s from @%s", cmd.action, author) - continue - - logger.info("Executing command: %s from @%s on %s", cmd.action, author, ghsa_id) - try: - result = execute_command(cmd, github, playwright_client, owner, repo, ghsa_id) - post_ghsa_comment(playwright_client, owner, repo, ghsa_id, result.message) - state_manager.mark_command_processed(ghsa_key, comment_id, body, author) - commands_executed += 1 - logger.info("Command executed successfully: %s", cmd.action) - except Exception: - logger.exception("Command execution failed: %s from @%s on %s", cmd.action, author, ghsa_id) - error_message = ( - f"❌ **Command Execution Failed**\n\n" - f"@{author}, an error occurred while processing your command.\n\n" - f"Please contact the PSRT team if this error persists." - ) - - with contextlib.suppress(Exception): - post_ghsa_comment(playwright_client, owner, repo, ghsa_id, error_message) - - return commands_executed + logger.info("Found %d comments on %s", len(comments), ghsa_id) + return len(comments) def process_all_comments( @@ -162,15 +112,13 @@ def process_all_comments( stats.ghsas_checked += 1 logger.info("Checking GHSA: %s/%s/%s (state: %s)", owner, repo_name, ghsa_id, state_str) try: - commands_executed = process_ghsa_comments( - installation_github, + comments_found = process_ghsa_comments( playwright_client, - state_manager, owner, repo_name, ghsa_id, ) - stats.commands_executed += commands_executed + stats.comments_found += comments_found except Exception: logger.exception("Error processing %s", ghsa_id) stats.errors += 1 @@ -207,7 +155,7 @@ def main() -> None: logger.info("=" * 50) logger.info("Processing Summary:") logger.info(" GHSAs checked: %d", stats.ghsas_checked) - logger.info(" Commands executed: %d", stats.commands_executed) + logger.info(" Comments found: %d", stats.comments_found) logger.info(" Errors: %d", stats.errors) logger.info("=" * 50) diff --git a/tests/test_commands.py b/tests/test_commands.py deleted file mode 100644 index 99c5b85..0000000 --- a/tests/test_commands.py +++ /dev/null @@ -1,401 +0,0 @@ -"""Tests for command parsing system. - -todo: test for auth checks to make sure they are handled properly -""" - -import os -from datetime import UTC, datetime - -import pytest - -from psrt_ghsa_bot.commands.parser import ( - AVAILABLE_COMMANDS, - Command, - get_help_text, - get_unknown_command_response, - is_valid_command, - parse_command, -) - - -@pytest.fixture -def bot_username() -> str: - """Get bot username from environment or use default.""" - return os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") - - -class TestCommandParsing: - """Test command parsing from comment text.""" - - def test_parse_simple_command(self) -> None: - """Test parsing a simple command without arguments.""" - result = parse_command( - "@psrt-ghsabot help", - "testuser", - "comment-1", - "psrt-ghsabot", - datetime(2024, 1, 1, 12, 0, 0), - ) - - assert result is not None - assert result.action == "help" - assert result.arguments == [] - assert result.author == "testuser" - assert result.comment_id == "comment-1" - assert result.timestamp == datetime(2024, 1, 1, 12, 0, 0) - - def test_parse_command_with_single_argument(self) -> None: - """Test parsing command with one argument.""" - result = parse_command( - "@psrt-ghsabot reject CVE-2024-1234", - "maintainer", - "comment-2", - "psrt-ghsabot", - ) - - assert result is not None - assert result.action == "reject" - assert result.arguments == ["CVE-2024-1234"] - assert result.author == "maintainer" - - def test_parse_command_with_multiple_arguments(self) -> None: - """Test parsing command with multiple arguments.""" - result = parse_command( - "@psrt-ghsabot some-cmd arg1 arg2 arg3", - "user", - "comment-3", - "psrt-ghsabot", - ) - - assert result is not None - assert result.action == "some-cmd" - assert result.arguments == ["arg1", "arg2", "arg3"] - - def test_parse_command_case_insensitive(self) -> None: - """Test that bot mention and command are case-insensitive.""" - variations = [ - "@PSRT-GHSABOT help", - "@Psrt-GhsaBot help", - "@psrt-ghsabot HELP", - "@psrt-ghsabot Help", - ] - - for text in variations: - result = parse_command(text, "user", "comment", "psrt-ghsabot") - assert result is not None - assert result.action == "help" - - def test_parse_command_in_middle_of_text(self) -> None: - """Test parsing command embedded in larger comment.""" - comment = """ - I think we should handle this differently. - - @psrt-ghsabot reject CVE-2024-5678 - - Let me know if you agree. - """ - - result = parse_command(comment, "user", "comment", "psrt-ghsabot") - assert result is not None - assert result.action == "reject" - assert result.arguments == ["CVE-2024-5678"] - - def test_parse_command_with_extra_whitespace(self) -> None: - """Test parsing handles extra whitespace correctly.""" - result = parse_command( - "@psrt-ghsabot reject CVE-2024-9999", - "user", - "comment", - "psrt-ghsabot", - ) - - assert result is not None - assert result.action == "reject" - assert result.arguments == ["CVE-2024-9999"] - - def test_parse_command_with_alias(self) -> None: - """Test that command aliases work correctly.""" - result = parse_command( - "@psrt-ghsabot withdraw CVE-2024-1111", - "user", - "comment", - "psrt-ghsabot", - ) - - assert result is not None - assert result.action == "reject" - assert result.arguments == ["CVE-2024-1111"] - - def test_parse_publish_command(self) -> None: - """Test parsing publish command.""" - result = parse_command( - "@psrt-ghsabot publish", - "user", - "comment", - "psrt-ghsabot", - ) - - assert result is not None - assert result.action == "publish" - assert result.arguments == [] - - def test_parse_publish_aliases(self) -> None: - """Test that publish aliases work correctly.""" - for alias in ["release", "complete"]: - result = parse_command( - f"@psrt-ghsabot {alias}", - "user", - "comment", - "psrt-ghsabot", - ) - - assert result is not None - assert result.action == "publish" - - def test_parse_no_command(self) -> None: - """Test that None is returned when no command is found.""" - result = parse_command( - "Just a regular comment without bot mention", - "user", - "comment", - "psrt-ghsabot", - ) - - assert result is None - - def test_parse_incomplete_mention(self) -> None: - """Test that incomplete mentions don't parse.""" - result = parse_command("@psrt", "user", "comment", "psrt-ghsabot") - assert result is None - - result = parse_command("psrt-ghsabot help", "user", "comment", "psrt-ghsabot") - assert result is None - - def test_parse_empty_comment(self) -> None: - """Test parsing empty or None comment.""" - assert parse_command("", "user", "comment", "psrt-ghsabot") is None - assert parse_command(None, "user", "comment", "psrt-ghsabot") is None - - def test_parse_command_default_timestamp(self) -> None: - """Test that timestamp defaults to current time.""" - before = datetime.now(tz=UTC) - result = parse_command("@psrt-ghsabot help", "user", "comment", "psrt-ghsabot") - after = datetime.now(tz=UTC) - - assert result is not None - assert before <= result.timestamp <= after - - def test_parse_command_custom_bot_username(self) -> None: - """Test parsing with custom bot username.""" - result = parse_command( - "@my-custom-bot reject CVE-2024-9999", - "user", - "comment", - "my-custom-bot", - ) - - assert result is not None - assert result.action == "reject" - assert result.arguments == ["CVE-2024-9999"] - - def test_parse_command_username_with_special_chars(self) -> None: - """Test parsing bot username with regex special characters.""" - result = parse_command( - "@bot.test+dev reject CVE-2024-9999", - "user", - "comment", - "bot.test+dev", - ) - - assert result is not None - assert result.action == "reject" - - -class TestCommandValidation: - """Test command validation functions.""" - - def test_is_valid_command_recognized(self) -> None: - """Test that recognized commands are valid.""" - for cmd in AVAILABLE_COMMANDS: - assert is_valid_command(cmd) - - def test_is_valid_command_case_insensitive(self) -> None: - """Test validation is case-insensitive.""" - assert is_valid_command("HELP") - assert is_valid_command("Help") - assert is_valid_command("reject") - assert is_valid_command("REJECT") - - def test_is_valid_command_unrecognized(self) -> None: - """Test that unrecognized commands are invalid.""" - assert not is_valid_command("unknown") - assert not is_valid_command("foo") - assert not is_valid_command("delete-everything") - - def test_is_valid_command_publish(self) -> None: - """Test that publish command is recognized.""" - assert is_valid_command("publish") - assert is_valid_command("PUBLISH") - assert is_valid_command("Publish") - - -class TestHelpText: - """Test help text generation.""" - - def test_get_help_text_format(self) -> None: - """Test that help text is properly formatted.""" - help_text = get_help_text("psrt-ghsabot") - - assert "PSRT GHSA Bot Commands" in help_text - assert "Available commands:" in help_text - - for cmd_name, cmd_info in AVAILABLE_COMMANDS.items(): - assert cmd_name in help_text.lower() - assert cmd_info["description"] in help_text - assert f"@psrt-ghsabot {cmd_info['usage']}" in help_text - assert f"@psrt-ghsabot {cmd_info['example']}" in help_text - - def test_get_help_text_includes_all_commands(self) -> None: - """Test that all commands are documented in help.""" - help_text = get_help_text("psrt-ghsabot") - - for cmd_name in AVAILABLE_COMMANDS: - assert cmd_name in help_text.lower() - - def test_get_help_text_shows_aliases(self) -> None: - """Test that command aliases are shown in help.""" - help_text = get_help_text("psrt-ghsabot") - - assert "withdraw" in help_text - assert "request-cve" in help_text - assert "release" in help_text - assert "complete" in help_text - - def test_get_help_text_custom_bot_username(self) -> None: - """Test help text with custom bot username.""" - help_text = get_help_text("my-custom-bot") - - assert "@my-custom-bot help" in help_text - assert "@my-custom-bot reject" in help_text - assert "@my-custom-bot publish" in help_text - assert "@psrt-ghsabot" not in help_text - - -class TestUnknownCommandResponse: - """Test unknown command response generation.""" - - def test_get_unknown_command_response_includes_action(self) -> None: - """Test that response includes the unknown action.""" - response = get_unknown_command_response("foo") - - assert "foo" in response - assert "Unknown command" in response - - def test_get_unknown_command_response_lists_available(self) -> None: - """Test that response lists available commands.""" - response = get_unknown_command_response("invalid") - - for cmd in AVAILABLE_COMMANDS: - assert cmd in response.lower() - - def test_get_unknown_command_response_suggests_help(self, bot_username: str) -> None: - """Test that response suggests using help.""" - response = get_unknown_command_response("bad", bot_username) - - assert "help" in response.lower() - assert f"@{bot_username} help" in response - - -class TestCommandRepresentation: - """Test Command dataclass methods.""" - - def test_command_repr(self) -> None: - """Test Command __repr__ output.""" - cmd = Command( - action="reject", - arguments=["CVE-2024-1234"], - author="testuser", - comment_id="comment-1", - timestamp=datetime(2024, 1, 1), - ) - - repr_str = repr(cmd) - assert "reject" in repr_str - assert "CVE-2024-1234" in repr_str - assert "testuser" in repr_str - - def test_command_repr_no_arguments(self) -> None: - """Test Command __repr__ with no arguments.""" - cmd = Command( - action="help", - arguments=[], - author="user", - comment_id="comment", - timestamp=datetime.now(), - ) - - repr_str = repr(cmd) - assert "help" in repr_str - assert "(no args)" in repr_str - - -class TestEdgeCases: - """Test edge cases and error conditions.""" - - def test_command_with_special_characters_in_args(self) -> None: - """Test parsing arguments with special characters.""" - result = parse_command( - "@psrt-ghsabot test arg-with-dash arg_with_underscore", - "user", - "comment", - "psrt-ghsabot", - ) - - assert result is not None - assert result.arguments == ["arg-with-dash", "arg_with_underscore"] - - def test_multiple_bot_mentions(self) -> None: - """Test that only first mention is parsed.""" - comment = """ - @psrt-ghsabot help - - Actually, wait: - @psrt-ghsabot status - """ - - result = parse_command(comment, "user", "comment", "psrt-ghsabot") - assert result is not None - assert result.action == "help" - - def test_command_at_start_of_line(self) -> None: - """Test command at beginning of comment.""" - result = parse_command( - "@psrt-ghsabot reject CVE-2024-1234", - "user", - "comment", - "psrt-ghsabot", - ) - assert result is not None - - def test_command_at_end_of_line(self) -> None: - """Test command at end of comment.""" - result = parse_command( - "Here's my command: @psrt-ghsabot help", - "user", - "comment", - "psrt-ghsabot", - ) - assert result is not None - - def test_command_on_its_own_line(self) -> None: - """Test command on a line by itself.""" - comment = """ - Some context here. - - @psrt-ghsabot reject CVE-2024-1234 - - More context below. - """ - result = parse_command(comment, "user", "comment", "psrt-ghsabot") - assert result is not None - assert result.action == "reject" diff --git a/tests/test_executor.py b/tests/test_executor.py deleted file mode 100644 index bf89637..0000000 --- a/tests/test_executor.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Tests for command execution.""" - -from datetime import datetime -from unittest.mock import Mock - -import pytest - -from psrt_ghsa_bot.commands import CommandResult, execute_command -from psrt_ghsa_bot.commands.parser import Command - - -@pytest.fixture -def mock_github(): - """Create mock GitHub client.""" - return Mock() - - -@pytest.fixture -def mock_playwright(): - """Create mock Playwright client.""" - mock = Mock() - mock.username = "test-bot" - return mock - - -@pytest.fixture -def sample_command(): - """Create sample command.""" - return Command( - action="help", - arguments=[], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - -class TestCommandExecution: - """Test command execution.""" - - def test_execute_help_command(self, sample_command, mock_github, mock_playwright) -> None: - """Test executing help command.""" - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - result = execute_command( - sample_command, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert isinstance(result, CommandResult) - assert result.success - assert "PSRT GHSA Bot Commands" in result.message - assert "@test-bot help" in result.message - - def test_execute_status_command_stub(self, mock_github, mock_playwright) -> None: - """Test executing status command (stub).""" - cmd = Command( - action="status", - arguments=[], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - advisory_response = Mock() - advisory_response.parsed_data = Mock( - state="draft", - cve_id="CVE-2024-1234", - created_at="2024-01-01T00:00:00Z", - updated_at="2024-01-15T00:00:00Z", - ) - mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response - - result = execute_command( - cmd, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert result.success - assert "Advisory Status" in result.message - assert "GHSA-1234" in result.message - assert "draft" in result.message - - def test_execute_reject_command_with_cve(self, mock_github, mock_playwright) -> None: - """Test executing reject command with CVE ID.""" - cmd = Command( - action="reject", - arguments=["CVE-2024-1234"], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - advisory_response = Mock() - advisory_response.parsed_data = Mock(cve_id="CVE-2024-1234") - mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response - - result = execute_command( - cmd, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert result.success - assert "CVE Rejected" in result.message - assert "CVE-2024-1234" in result.message - - def test_execute_reject_without_cve_fails(self, mock_github, mock_playwright) -> None: - """Test executing reject command without CVE ID fails.""" - cmd = Command( - action="reject", - arguments=[], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - result = execute_command( - cmd, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert not result.success - assert "Missing CVE ID" in result.message - - def test_execute_assign_cve_command(self, mock_github, mock_playwright) -> None: - """Test executing assign-cve command.""" - cmd = Command( - action="assign-cve", - arguments=[], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - advisory_response = Mock() - advisory_response.parsed_data = Mock(cve_id=None) - mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response - - result = execute_command( - cmd, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - # Assign CVE requires CVE API credentials which won't be available in tests - # So we expect it to fail but still test it's calling the right handler - assert "assign" in result.message.lower() or "cve" in result.message.lower() - - def test_execute_publish_command(self, mock_github, mock_playwright) -> None: - """Test executing publish command.""" - cmd = Command( - action="publish", - arguments=[], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - result = execute_command( - cmd, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert result.success - assert "Publish Advisory" in result.message - - def test_execute_unknown_command(self, mock_github, mock_playwright) -> None: - """Test executing unknown command.""" - cmd = Command( - action="unknown-action", - arguments=[], - author="testuser", - comment_id="comment-1", - timestamp=datetime.now(), - ) - - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) - - result = execute_command( - cmd, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert not result.success - assert "Unknown command" in result.message - assert "unknown-action" in result.message - - def test_execute_unauthorized_user(self, sample_command, mock_github, mock_playwright) -> None: - """Test executing command as unauthorized user.""" - mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=404) - mock_github.rest.security_advisories.get_repository_advisory.side_effect = Exception("Not found") - mock_github.rest.repos.get_collaborator_permission_level.side_effect = Exception("Not found") - - result = execute_command( - sample_command, - mock_github, - mock_playwright, - "python", - "cpython", - "GHSA-1234", - ) - - assert not result.success - assert "Unauthorized" in result.message - assert "testuser" in result.message From b4238fb4dd46c6ed176ba292684f366b2774e1b0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:45:12 -0600 Subject: [PATCH 43/64] wrap in run load in try/exc --- src/psrt_ghsa_bot/health_check.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index 7fefc36..e2f59a9 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -57,7 +57,13 @@ def check_workflow_health() -> None: all_healthy = False continue - runs = json.loads(result.stdout) + try: + runs = json.loads(result.stdout) + except ValueError: + logger.warning("Failed to parse workflow runs for %s", workflow["name"]) + all_healthy = False + continue + if not runs: logger.warning("No runs found for %s", workflow["name"]) continue From fa408e051951444aee70c2960b7ad9745e3feb19 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 10:57:31 -0600 Subject: [PATCH 44/64] why be fancy? no names pls! --- src/psrt_ghsa_bot/health_check.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index e2f59a9..f7c0da3 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -19,8 +19,8 @@ logger = logging.getLogger(__name__) WORKFLOWS_TO_CHECK = [ - {"name": "PSRT GHSA Bot", "file": "cron.yml", "monitor_slug": MONITOR_SLUG_GHSA}, - {"name": "PSRT Playwright Bot", "file": "playwright.yml", "monitor_slug": MONITOR_SLUG_PLAYWRIGHT}, + {"file": "cron.yml", "monitor_slug": MONITOR_SLUG_GHSA}, + {"file": "playwright.yml", "monitor_slug": MONITOR_SLUG_PLAYWRIGHT}, ] @@ -33,7 +33,7 @@ def check_workflow_health() -> None: all_healthy = True for workflow in WORKFLOWS_TO_CHECK: - logger.info("Checking workflow: %s", workflow["name"]) + logger.info("Checking workflow: %s", workflow["file"]) result = subprocess.run( # noqa: S603 [ # noqa: S607 @@ -60,17 +60,17 @@ def check_workflow_health() -> None: try: runs = json.loads(result.stdout) except ValueError: - logger.warning("Failed to parse workflow runs for %s", workflow["name"]) + logger.warning("Failed to parse workflow runs for %s", workflow["file"]) all_healthy = False continue if not runs: - logger.warning("No runs found for %s", workflow["name"]) + logger.warning("No runs found for %s", workflow["file"]) continue completed_runs = [r for r in runs if r["status"] == "completed"] if not completed_runs: - logger.info("No completed runs yet for %s", workflow["name"]) + logger.info("No completed runs yet for %s", workflow["file"]) continue latest_run = completed_runs[0] @@ -80,7 +80,7 @@ def check_workflow_health() -> None: if conclusion in ["failure", "timed_out", "cancelled"]: logger.error("Workflow failed with status: %s", conclusion) - report_workflow_failure(workflow["name"], str(run_id), conclusion) + report_workflow_failure(workflow["file"], str(run_id), conclusion) capture_checkin(workflow["monitor_slug"], "error") all_healthy = False elif conclusion == "success": From 7924a1252bf4ccb32113e21778373cfddeb64b45 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:11:15 -0600 Subject: [PATCH 45/64] make fixture out of large pytest skipif --- tests/conftest.py | 13 +++++++++++++ tests/test_comments.py | 34 +++++++--------------------------- tests/test_playwright_base.py | 18 ++++-------------- 3 files changed, 24 insertions(+), 41 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..53f7a95 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +"""Shared pytest config & fixtures.""" + +from pathlib import Path + +import pytest + +PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() +AUTH_STATE_EXISTS = Path("playwright/.auth/github_state.json").exists() + +requires_playwright_auth = pytest.mark.skipif( + not (PLAYWRIGHT_FULL and AUTH_STATE_EXISTS), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", +) diff --git a/tests/test_comments.py b/tests/test_comments.py index 3f0bf0e..0f21eb4 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -2,12 +2,12 @@ import os from datetime import datetime -from pathlib import Path from typing import TYPE_CHECKING import pytest from githubkit import GitHub, TokenAuthStrategy +from conftest import requires_playwright_auth from psrt_ghsa_bot.polyfills import ( GHSAComment, GitHubPlaywrightClient, @@ -18,8 +18,6 @@ if TYPE_CHECKING: from collections.abc import Generator -PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() - @pytest.fixture def authenticated_client() -> Generator[GitHubPlaywrightClient]: @@ -112,10 +110,7 @@ def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient) - # assert comment.created_at <= comment.updated_at -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_ghsa_comment_dataclass_repr() -> None: """Test the GHSAComment repr method.""" comment = GHSAComment( @@ -134,10 +129,7 @@ def test_ghsa_comment_dataclass_repr() -> None: assert "2024-01-01 12:00:00" in repr_str -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient) -> None: """Test retrieval from a GHSA with no comments.""" # Create or find a GHSA with zero comments @@ -153,10 +145,7 @@ def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrig assert isinstance(comments, list) -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient) -> None: """Test that bot comments are properly detected.""" comments = get_ghsa_comments( @@ -173,10 +162,7 @@ def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightC assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient) -> None: """Test that comments are returned in chronological order.""" comments = get_ghsa_comments( @@ -211,10 +197,7 @@ def test_ghsa_comment_dataclass_fields() -> None: assert comment.is_bot_comment is False -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: """Test error handling for invalid GHSA ID.""" with GitHubPlaywrightClient(headless=True) as client: @@ -279,10 +262,7 @@ def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, t assert len(comment_id) > 0 -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient) -> None: """Test posting a comment and then reading it back.""" unique_text = f"Roundtrip test {datetime.now().timestamp()}" diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py index 6173338..c0ae2a6 100644 --- a/tests/test_playwright_base.py +++ b/tests/test_playwright_base.py @@ -4,10 +4,9 @@ import pytest +from conftest import requires_playwright_auth from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient -PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() - @pytest.fixture def client() -> GitHubPlaywrightClient: @@ -40,10 +39,7 @@ def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: assert "github.com" in client.page.url -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None: """Test authentication using saved state from manual login.""" with client: @@ -51,10 +47,7 @@ def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None assert client._is_authenticated() -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: """Test navigation to a GHSA page (requires authentication).""" with client: @@ -67,10 +60,7 @@ def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: assert "security/advisories" in client.page.url -@pytest.mark.skipif( - not (PLAYWRIGHT_FULL and Path("playwright/.auth/github_state.json").exists()), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +@requires_playwright_auth def test_authentication_state_persistence(client: GitHubPlaywrightClient) -> None: """Test that authentication state is saved and can be reused.""" storage_state_path = Path("playwright/.auth/github_state.json") From cd82f98cfed987913f37145c9f075a92bcad422c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:18:26 -0600 Subject: [PATCH 46/64] refactor health check to use new status tracking --- src/psrt_ghsa_bot/health_check.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index f7c0da3..1943af3 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -27,10 +27,8 @@ def check_workflow_health() -> None: """Check the health of configured workflows and report to Sentry.""" init_sentry() - capture_checkin(MONITOR_SLUG_HEALTH, "in_progress") - - all_healthy = True + workflow_statuses = {workflow["file"]: False for workflow in WORKFLOWS_TO_CHECK} for workflow in WORKFLOWS_TO_CHECK: logger.info("Checking workflow: %s", workflow["file"]) @@ -54,14 +52,12 @@ def check_workflow_health() -> None: if result.returncode != 0: logger.warning("Failed to get workflow runs: %s", result.stderr) - all_healthy = False continue try: runs = json.loads(result.stdout) except ValueError: logger.warning("Failed to parse workflow runs for %s", workflow["file"]) - all_healthy = False continue if not runs: @@ -82,15 +78,14 @@ def check_workflow_health() -> None: logger.error("Workflow failed with status: %s", conclusion) report_workflow_failure(workflow["file"], str(run_id), conclusion) capture_checkin(workflow["monitor_slug"], "error") - all_healthy = False elif conclusion == "success": logger.info("Workflow succeeded") capture_checkin(workflow["monitor_slug"], "ok") + workflow_statuses[workflow["file"]] = True else: logger.warning("Unexpected conclusion: %s", conclusion) - all_healthy = False - if all_healthy: + if all(workflow_statuses.values()): capture_checkin(MONITOR_SLUG_HEALTH, "ok") logger.info("All workflows healthy") else: From f482e9b551f2dffe1ac1f93fdfe45daeedee32fb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:27:47 -0600 Subject: [PATCH 47/64] piece out integration test --- pyproject.toml | 3 +- tests/integration/__init__.py | 1 + tests/integration/conftest.py | 83 +++++++++++++ tests/integration/test_comments.py | 173 +++++++++++++++++++++++++++ tests/integration/test_playwright.py | 69 +++++++++++ 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_comments.py create mode 100644 tests/integration/test_playwright.py diff --git a/pyproject.toml b/pyproject.toml index 54789ec..757046b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,4 +98,5 @@ indent-style = "space" convention = "google" [tool.pytest.ini_options] -addopts = "-p no:anyio" +addopts = "-p no:anyio --ignore=tests/integration" +testpaths = ["tests"] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..39befb6 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests that hit live GitHub APIs (or pages).""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..705d592 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,83 @@ +"""Shared fixtures for integration tests.""" + +import os +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from githubkit import GitHub, TokenAuthStrategy + +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient + +if TYPE_CHECKING: + from collections.abc import Generator + +PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() +AUTH_STATE_EXISTS = Path("playwright/.auth/github_state.json").exists() + +requires_playwright_auth = pytest.mark.skipif( + not (PLAYWRIGHT_FULL and AUTH_STATE_EXISTS), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", +) + + +@pytest.fixture +def client() -> GitHubPlaywrightClient: + """Create a GitHubPlaywrightClient instance for testing.""" + return GitHubPlaywrightClient(headless=True) + + +@pytest.fixture +def authenticated_client() -> Generator[GitHubPlaywrightClient]: + """Create an authenticated GitHubPlaywrightClient instance for testing.""" + client = GitHubPlaywrightClient(headless=True) + client.start() + client.authenticate() + yield client + client.close() + + +@pytest.fixture +def test_ghsa() -> Generator[dict[str, str]]: + """Create a test GHSA and clean it up after the test. + + Returns dict with: owner, repo, ghsa_id + """ + token = os.getenv("GH_AUTH_TOKEN") + if not token: + pytest.skip("GH_AUTH_TOKEN not set") + + github = GitHub(TokenAuthStrategy(token)) # type: ignore[arg-type] + owner = "jolt-org" + repo = "ghsa-testing" + + response = github.rest.security_advisories.create_repository_advisory( + owner=owner, + repo=repo, + data={ + "summary": f"Test Advisory {datetime.now().timestamp()}", + "description": "This is a test advisory created by pytest. It will be deleted automatically.", + "severity": "low", + "vulnerabilities": [ + { + "package": {"ecosystem": "pip", "name": "test-package"}, + "vulnerable_version_range": "< 1.0.0", + } + ], + }, + ) + + ghsa_id = response.parsed_data.ghsa_id + + yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} + + try: + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"state": "closed"}, + ) + except Exception: + pass diff --git a/tests/integration/test_comments.py b/tests/integration/test_comments.py new file mode 100644 index 0000000..46703a8 --- /dev/null +++ b/tests/integration/test_comments.py @@ -0,0 +1,173 @@ +"""Integration tests for GHSA comment functionality (hit live GitHub).""" + +from datetime import datetime + +import pytest +from tests.integration.conftest import requires_playwright_auth + +from psrt_ghsa_bot.polyfills import ( + GitHubPlaywrightClient, + get_ghsa_comments, + post_ghsa_comment, +) + + +@requires_playwright_auth +def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient) -> None: + """Test basic comment retrieval from a GHSA.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + assert isinstance(comments, list) + + +@requires_playwright_auth +def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient) -> None: + """Test retrieval from a GHSA with no comments.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + assert isinstance(comments, list) + + +@requires_playwright_auth +def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient) -> None: + """Test that bot comments are properly detected.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + bot_comments = [c for c in comments if c.is_bot_comment] + for bot_comment in bot_comments: + assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author + + +@requires_playwright_auth +def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient) -> None: + """Test that comments are returned in chronological order.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + if len(comments) >= 2: + for i in range(len(comments) - 1): + assert comments[i].created_at <= comments[i + 1].created_at + + +@requires_playwright_auth +def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: + """Test error handling for invalid GHSA ID.""" + with GitHubPlaywrightClient(headless=True) as client: + client.authenticate() + + with pytest.raises((PermissionError, Exception)) as exc_info: + get_ghsa_comments( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-0000-0000-0000", + ) + + if exc_info.type is not PermissionError: + error_msg = str(exc_info.value).lower() + assert "ghsa" in error_msg or "404" in error_msg or "not found" in error_msg + + +@requires_playwright_auth +def test_get_ghsa_comments_pagination() -> None: + """Test pagination handling for GHSAs with many comments.""" + with GitHubPlaywrightClient(headless=True) as client: + client.authenticate() + + comments = get_ghsa_comments( + client, + owner="test-org", + repo="test-repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + ) + + assert len(comments) > 20 + + +@requires_playwright_auth +def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, test_ghsa: dict[str, str]) -> None: + """Test basic comment posting to a GHSA.""" + test_comment = f"Test comment from pytest at {datetime.now().isoformat()}" + + comment_id = post_ghsa_comment( + authenticated_client, + owner=test_ghsa["owner"], + repo=test_ghsa["repo"], + ghsa_id=test_ghsa["ghsa_id"], + comment_body=test_comment, + ) + + assert isinstance(comment_id, str) + assert len(comment_id) > 0 + + +@requires_playwright_auth +def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient) -> None: + """Test posting a comment and then reading it back.""" + unique_text = f"Roundtrip test {datetime.now().timestamp()}" + + comment_id = post_ghsa_comment( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=unique_text, + ) + + assert comment_id is not None + + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + posted_comment = next((c for c in comments if unique_text in c.body), None) + assert posted_comment is not None + assert posted_comment.body == unique_text + + +@requires_playwright_auth +def test_post_comment_with_markdown(authenticated_client: GitHubPlaywrightClient) -> None: + """Test posting a comment with markdown formatting.""" + markdown_comment = f"""# Test Comment {datetime.now().timestamp()} + +This comment contains **bold**, *italic*, and `code`. + +- List item 1 +- List item 2 + +```python +def hello(): + return "world" +``` +""" + + comment_id = post_ghsa_comment( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=markdown_comment, + ) + + assert isinstance(comment_id, str) + assert len(comment_id) > 0 diff --git a/tests/integration/test_playwright.py b/tests/integration/test_playwright.py new file mode 100644 index 0000000..377f869 --- /dev/null +++ b/tests/integration/test_playwright.py @@ -0,0 +1,69 @@ +"""Integration tests for Playwright client (hit live GitHub).""" + +from pathlib import Path + +import pytest +from tests.integration.conftest import requires_playwright_auth + +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + + +def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: + """Test navigation to a public GitHub page.""" + with client: + client.page.goto("https://github.com") + assert "github.com" in client.page.url + + +@requires_playwright_auth +def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None: + """Test authentication using saved state from manual login.""" + with client: + client.authenticate() + assert client._is_authenticated() + + +@requires_playwright_auth +def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: + """Test navigation to a GHSA page (requires authentication).""" + with client: + client.authenticate() + client.navigate_to_ghsa("jolt-org", "ghsa-testing", "GHSA-f3x5-4pp6-r2mf") + + assert "GHSA-f3x5-4pp6-r2mf" in client.page.url + assert "security/advisories" in client.page.url + + +@requires_playwright_auth +def test_authentication_state_persistence(client: GitHubPlaywrightClient) -> None: + """Test that authentication state is saved and can be reused.""" + storage_state_path = Path("playwright/.auth/github_state.json") + + with client: + if storage_state_path.exists(): + assert client._is_authenticated() + + assert storage_state_path.exists() + + +def test_wait_for_page_ready(client: GitHubPlaywrightClient) -> None: + """Test the wait_for_page_ready helper.""" + with client: + client.page.goto("https://github.com") + client.wait_for_page_ready(timeout=10000) + + +@pytest.mark.skip(reason="Manual test - run explicitly to set up initial authentication") +def test_manual_authentication() -> None: + """Test manual authentication flow. + + This test is marked as manual and should be run explicitly when needed + to set up initial authentication. + + Only really for localdev because we want CI to be automagic so use PAT for that. + + Run with: pytest tests/integration/test_playwright.py::test_manual_authentication -v + """ + with GitHubPlaywrightClient(headless=False) as client: + client.authenticate_manual(timeout=120000) + assert client._is_authenticated() From 881ca22973ae6c959d4b3d0ea391b42716c4fc30 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:27:58 -0600 Subject: [PATCH 48/64] clean up unit tests --- tests/conftest.py | 14 +- tests/test_comments.py | 274 +--------------------------------- tests/test_playwright_base.py | 70 +-------- 3 files changed, 3 insertions(+), 355 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 53f7a95..72703ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1 @@ -"""Shared pytest config & fixtures.""" - -from pathlib import Path - -import pytest - -PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() -AUTH_STATE_EXISTS = Path("playwright/.auth/github_state.json").exists() - -requires_playwright_auth = pytest.mark.skipif( - not (PLAYWRIGHT_FULL and AUTH_STATE_EXISTS), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) +"""Shared pytest config & fixtures for unit tests.""" diff --git a/tests/test_comments.py b/tests/test_comments.py index 0f21eb4..603ac74 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,116 +1,16 @@ -"""Tests for GHSA comment functionality (reading and writing).""" +"""Unit tests for GHSA comment functionality.""" -import os from datetime import datetime -from typing import TYPE_CHECKING import pytest -from githubkit import GitHub, TokenAuthStrategy -from conftest import requires_playwright_auth from psrt_ghsa_bot.polyfills import ( GHSAComment, GitHubPlaywrightClient, - get_ghsa_comments, post_ghsa_comment, ) -if TYPE_CHECKING: - from collections.abc import Generator - -@pytest.fixture -def authenticated_client() -> Generator[GitHubPlaywrightClient]: - """Create an authenticated GitHubPlaywrightClient instance for testing.""" - client = GitHubPlaywrightClient(headless=True) - client.start() - client.authenticate() - yield client - client.close() - - -@pytest.fixture -def test_ghsa() -> Generator[dict[str, str]]: - """Create a test GHSA and clean it up after the test. - - Returns dict with: owner, repo, ghsa_id - """ - token = os.getenv("GH_AUTH_TOKEN") - if not token: - pytest.skip("GH_AUTH_TOKEN not set") - - github = GitHub(TokenAuthStrategy(token)) # type: ignore[arg-type] - owner = "jolt-org" - repo = "ghsa-testing" - - # Create a test advisory - response = github.rest.security_advisories.create_repository_advisory( - owner=owner, - repo=repo, - data={ - "summary": f"Test Advisory {datetime.now().timestamp()}", - "description": "This is a test advisory created by pytest. It will be deleted automatically.", - "severity": "low", - "vulnerabilities": [ - { - "package": {"ecosystem": "pip", "name": "test-package"}, - "vulnerable_version_range": "< 1.0.0", - } - ], - }, - ) - - ghsa_id = response.parsed_data.ghsa_id - - yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} - - # Cleanup: Close/delete the advisory - # Note: GitHub doesn't allow deleting advisories via API, but we can close them - try: - github.rest.security_advisories.update_repository_advisory( - owner=owner, - repo=repo, - ghsa_id=ghsa_id, - data={"state": "closed"}, - ) - except Exception: - pass # Best effort cleanup - - -@pytest.mark.skip(reason="idk how to test this actually") -def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient) -> None: - """Test basic comment retrieval from a GHSA.""" - # Use a test GHSA that we know has comments - get_ghsa_comments( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - ) - # - # # Verify we got a list (may be empty if no comments exist) - # assert isinstance(comments, list) - # - # # If there are comments, verify their structure - # for comment in comments: - # assert isinstance(comment, GHSAComment) - # assert isinstance(comment.id, str) - # assert isinstance(comment.author, str) - # assert isinstance(comment.body, str) - # assert isinstance(comment.created_at, datetime) - # assert isinstance(comment.updated_at, datetime) - # assert isinstance(comment.is_bot_comment, bool) - # - # # Basic validation - # assert len(comment.id) > 0 - # assert len(comment.author) > 0 - # # Body can be empty - # assert comment.created_at <= datetime.now() - # assert comment.updated_at <= datetime.now() - # assert comment.created_at <= comment.updated_at - - -@requires_playwright_auth def test_ghsa_comment_dataclass_repr() -> None: """Test the GHSAComment repr method.""" comment = GHSAComment( @@ -129,55 +29,6 @@ def test_ghsa_comment_dataclass_repr() -> None: assert "2024-01-01 12:00:00" in repr_str -@requires_playwright_auth -def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient) -> None: - """Test retrieval from a GHSA with no comments.""" - # Create or find a GHSA with zero comments - # For now, we'll just verify the function handles empty comment lists - comments = get_ghsa_comments( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", # May or may not have comments - ) - - # Should return an empty list if no comments, not error - assert isinstance(comments, list) - - -@requires_playwright_auth -def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient) -> None: - """Test that bot comments are properly detected.""" - comments = get_ghsa_comments( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - ) - - # Check if any bot comments are detected - bot_comments = [c for c in comments if c.is_bot_comment] - - for bot_comment in bot_comments: - assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author - - -@requires_playwright_auth -def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient) -> None: - """Test that comments are returned in chronological order.""" - comments = get_ghsa_comments( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - ) - - # If we have multiple comments, verify they're in chronological order - if len(comments) >= 2: - for i in range(len(comments) - 1): - assert comments[i].created_at <= comments[i + 1].created_at - - def test_ghsa_comment_dataclass_fields() -> None: """Test that GHSAComment has all required fields.""" comment = GHSAComment( @@ -197,101 +48,6 @@ def test_ghsa_comment_dataclass_fields() -> None: assert comment.is_bot_comment is False -@requires_playwright_auth -def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: - """Test error handling for invalid GHSA ID.""" - with GitHubPlaywrightClient(headless=True) as client: - client.authenticate() - - # Try to get comments from a non-existent GHSA - # Should raise PermissionError for 404/access denied - with pytest.raises((PermissionError, Exception)) as exc_info: - get_ghsa_comments( - client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-0000-0000-0000", - ) - - if exc_info.type is not PermissionError: - error_msg = str(exc_info.value).lower() - assert "ghsa" in error_msg or "404" in error_msg or "not found" in error_msg - - -@pytest.mark.skip(reason="Integration test - requires specific test GHSA with known comment count") -def test_get_ghsa_comments_pagination() -> None: - """Test pagination handling for GHSAs with many comments. - - This test should be run against a GHSA with enough comments to trigger - pagination (typically >20 comments). - """ - with GitHubPlaywrightClient(headless=True) as client: - client.authenticate() - - # TODO: Create or find a test GHSA with >20 comments - comments = get_ghsa_comments( - client, - owner="test-org", - repo="test-repo", - ghsa_id="GHSA-xxxx-xxxx-xxxx", - ) - - # Verify we got all comments, not just the first page - assert len(comments) > 20 - - -# ============================================================================ -# POST COMMENT TESTS -# ============================================================================ - - -@pytest.mark.skip(reason="Requires test GHSA creation (needs write permissions)") -def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, test_ghsa: dict[str, str]) -> None: - """Test basic comment posting to a GHSA.""" - test_comment = f"Test comment from pytest at {datetime.now().isoformat()}" - - comment_id = post_ghsa_comment( - authenticated_client, - owner=test_ghsa["owner"], - repo=test_ghsa["repo"], - ghsa_id=test_ghsa["ghsa_id"], - comment_body=test_comment, - ) - - assert isinstance(comment_id, str) - assert len(comment_id) > 0 - - -@requires_playwright_auth -def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient) -> None: - """Test posting a comment and then reading it back.""" - unique_text = f"Roundtrip test {datetime.now().timestamp()}" - - # Post the comment - comment_id = post_ghsa_comment( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - comment_body=unique_text, - ) - - assert comment_id is not None - - # Read comments back - comments = get_ghsa_comments( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - ) - - # Find our comment - posted_comment = next((c for c in comments if unique_text in c.body), None) - assert posted_comment is not None - assert posted_comment.body == unique_text - - def test_post_comment_empty_body_error() -> None: """Test that posting an empty comment raises ValueError.""" client = GitHubPlaywrightClient(headless=True) @@ -316,31 +72,3 @@ def test_post_comment_whitespace_only_error() -> None: ghsa_id="GHSA-f3x5-4pp6-r2mf", comment_body=" \n\t ", ) - - -@pytest.mark.skip(reason="Manual test - posts to real GHSA") -def test_post_comment_with_markdown(authenticated_client: GitHubPlaywrightClient) -> None: - """Test posting a comment with markdown formatting.""" - markdown_comment = f"""# Test Comment {datetime.now().timestamp()} - -This comment contains **bold**, *italic*, and `code`. - -- List item 1 -- List item 2 - -```python -def hello(): - return "world" -``` -""" - - comment_id = post_ghsa_comment( - authenticated_client, - owner="jolt-org", - repo="ghsa-testing", - ghsa_id="GHSA-f3x5-4pp6-r2mf", - comment_body=markdown_comment, - ) - - assert isinstance(comment_id, str) - assert len(comment_id) > 0 diff --git a/tests/test_playwright_base.py b/tests/test_playwright_base.py index c0ae2a6..8e6cb95 100644 --- a/tests/test_playwright_base.py +++ b/tests/test_playwright_base.py @@ -1,10 +1,7 @@ -"""Tests for the Playwright base client.""" - -from pathlib import Path +"""Unit tests for the Playwright base client.""" import pytest -from conftest import requires_playwright_auth from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient @@ -30,68 +27,3 @@ def test_client_start_and_close(client: GitHubPlaywrightClient) -> None: client.close() with pytest.raises(RuntimeError): _ = client.page - - -def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: - """Test navigation to a public GitHub page.""" - with client: - client.page.goto("https://github.com") - assert "github.com" in client.page.url - - -@requires_playwright_auth -def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None: - """Test authentication using saved state from manual login.""" - with client: - client.authenticate() - assert client._is_authenticated() - - -@requires_playwright_auth -def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: - """Test navigation to a GHSA page (requires authentication).""" - with client: - client.authenticate() - - # ! TODO: will need to use different org/repo later? - client.navigate_to_ghsa("jolt-org", "ghsa-testing", "GHSA-f3x5-4pp6-r2mf") - - assert "GHSA-f3x5-4pp6-r2mf" in client.page.url - assert "security/advisories" in client.page.url - - -@requires_playwright_auth -def test_authentication_state_persistence(client: GitHubPlaywrightClient) -> None: - """Test that authentication state is saved and can be reused.""" - storage_state_path = Path("playwright/.auth/github_state.json") - - with client: - if storage_state_path.exists(): - assert client._is_authenticated() - - assert storage_state_path.exists() - - -def test_wait_for_page_ready(client: GitHubPlaywrightClient) -> None: - """Test the wait_for_page_ready helper.""" - with client: - client.page.goto("https://github.com") - client.wait_for_page_ready(timeout=10000) - - -@pytest.mark.skip( - reason="Manual test - run explicitly with: pytest tests/test_playwright_base.py::test_manual_authentication -v" -) -def test_manual_authentication() -> None: - """Test manual authentication flow. - - This test is marked as manual and should be run explicitly when needed - to set up initial authentication. - / - Only really for localdev bcecause we want CI to be automagic ✨ so use PAT for that - - Run with: pytest tests/test_playwright_base.py::test_manual_authentication -v - """ - with GitHubPlaywrightClient(headless=False) as client: - client.authenticate_manual(timeout=120000) # 2 minutes - assert client._is_authenticated() From a14dd6f2cf26ac42154e7b9ba7e980c52bf2ac87 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:34:00 -0600 Subject: [PATCH 49/64] clean up unit tests --- pyproject.toml | 4 ++-- tests/__init__.py | 0 tests/unit/__init__.py | 1 + tests/{ => unit}/test_app.py | 0 tests/{ => unit}/test_comments.py | 0 tests/{ => unit}/test_playwright_base.py | 0 tests/{ => unit}/test_state.py | 0 7 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py rename tests/{ => unit}/test_app.py (100%) rename tests/{ => unit}/test_comments.py (100%) rename tests/{ => unit}/test_playwright_base.py (100%) rename tests/{ => unit}/test_state.py (100%) diff --git a/pyproject.toml b/pyproject.toml index 757046b..a524354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,5 +98,5 @@ indent-style = "space" convention = "google" [tool.pytest.ini_options] -addopts = "-p no:anyio --ignore=tests/integration" -testpaths = ["tests"] +addopts = "-p no:anyio" +testpaths = ["tests/unit"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests/test_app.py b/tests/unit/test_app.py similarity index 100% rename from tests/test_app.py rename to tests/unit/test_app.py diff --git a/tests/test_comments.py b/tests/unit/test_comments.py similarity index 100% rename from tests/test_comments.py rename to tests/unit/test_comments.py diff --git a/tests/test_playwright_base.py b/tests/unit/test_playwright_base.py similarity index 100% rename from tests/test_playwright_base.py rename to tests/unit/test_playwright_base.py diff --git a/tests/test_state.py b/tests/unit/test_state.py similarity index 100% rename from tests/test_state.py rename to tests/unit/test_state.py From 4d68d43f7ce190bdaf60e7084c4b43b421d09cb0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:34:26 -0600 Subject: [PATCH 50/64] move conf into one area, --- tests/conftest.py | 85 ++++++++++++++++++++++++++++++++++- tests/integration/conftest.py | 83 ---------------------------------- 2 files changed, 84 insertions(+), 84 deletions(-) delete mode 100644 tests/integration/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py index 72703ca..df8c510 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,84 @@ -"""Shared pytest config & fixtures for unit tests.""" +"""Shared pytest config & fixtures.""" + +import os +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient + +if TYPE_CHECKING: + from collections.abc import Generator + +PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() +AUTH_STATE_EXISTS = Path("playwright/.auth/github_state.json").exists() + +requires_playwright_auth = pytest.mark.skipif( + not (PLAYWRIGHT_FULL and AUTH_STATE_EXISTS), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", +) + + +@pytest.fixture +def client() -> GitHubPlaywrightClient: + """Create a GitHubPlaywrightClient instance for testing.""" + return GitHubPlaywrightClient(headless=True) + + +@pytest.fixture +def authenticated_client() -> Generator[GitHubPlaywrightClient]: + """Create an authenticated GitHubPlaywrightClient instance for testing.""" + client = GitHubPlaywrightClient(headless=True) + client.start() + client.authenticate() + yield client + client.close() + + +@pytest.fixture +def test_ghsa() -> Generator[dict[str, str]]: + """Create a test GHSA and clean it up after the test. + + Returns dict with: owner, repo, ghsa_id + """ + from githubkit import GitHub, TokenAuthStrategy # noqa: PLC0415 + + token = os.getenv("GH_AUTH_TOKEN") + if not token: + pytest.skip("GH_AUTH_TOKEN not set") + + github = GitHub(TokenAuthStrategy(token)) # type: ignore[arg-type] + owner = "jolt-org" + repo = "ghsa-testing" + + response = github.rest.security_advisories.create_repository_advisory( + owner=owner, + repo=repo, + data={ + "summary": f"Test Advisory {datetime.now().timestamp()}", + "description": "This is a test advisory created by pytest. It will be deleted automatically.", + "severity": "low", + "vulnerabilities": [ + { + "package": {"ecosystem": "pip", "name": "test-package"}, + "vulnerable_version_range": "< 1.0.0", + } + ], + }, + ) + + ghsa_id = response.parsed_data.ghsa_id + + yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} + + try: + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"state": "closed"}, + ) + except Exception: + pass diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py deleted file mode 100644 index 705d592..0000000 --- a/tests/integration/conftest.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Shared fixtures for integration tests.""" - -import os -from datetime import datetime -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest -from githubkit import GitHub, TokenAuthStrategy - -from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient - -if TYPE_CHECKING: - from collections.abc import Generator - -PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() -AUTH_STATE_EXISTS = Path("playwright/.auth/github_state.json").exists() - -requires_playwright_auth = pytest.mark.skipif( - not (PLAYWRIGHT_FULL and AUTH_STATE_EXISTS), - reason="Requires PLAYWRIGHT_FULL.test file and authentication state", -) - - -@pytest.fixture -def client() -> GitHubPlaywrightClient: - """Create a GitHubPlaywrightClient instance for testing.""" - return GitHubPlaywrightClient(headless=True) - - -@pytest.fixture -def authenticated_client() -> Generator[GitHubPlaywrightClient]: - """Create an authenticated GitHubPlaywrightClient instance for testing.""" - client = GitHubPlaywrightClient(headless=True) - client.start() - client.authenticate() - yield client - client.close() - - -@pytest.fixture -def test_ghsa() -> Generator[dict[str, str]]: - """Create a test GHSA and clean it up after the test. - - Returns dict with: owner, repo, ghsa_id - """ - token = os.getenv("GH_AUTH_TOKEN") - if not token: - pytest.skip("GH_AUTH_TOKEN not set") - - github = GitHub(TokenAuthStrategy(token)) # type: ignore[arg-type] - owner = "jolt-org" - repo = "ghsa-testing" - - response = github.rest.security_advisories.create_repository_advisory( - owner=owner, - repo=repo, - data={ - "summary": f"Test Advisory {datetime.now().timestamp()}", - "description": "This is a test advisory created by pytest. It will be deleted automatically.", - "severity": "low", - "vulnerabilities": [ - { - "package": {"ecosystem": "pip", "name": "test-package"}, - "vulnerable_version_range": "< 1.0.0", - } - ], - }, - ) - - ghsa_id = response.parsed_data.ghsa_id - - yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} - - try: - github.rest.security_advisories.update_repository_advisory( - owner=owner, - repo=repo, - ghsa_id=ghsa_id, - data={"state": "closed"}, - ) - except Exception: - pass From d135bd3d01475fc5f1095d45f9b8204004b8ae74 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:34:30 -0600 Subject: [PATCH 51/64] fmt --- tests/integration/test_comments.py | 2 +- tests/integration/test_playwright.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_comments.py b/tests/integration/test_comments.py index 46703a8..ff4f155 100644 --- a/tests/integration/test_comments.py +++ b/tests/integration/test_comments.py @@ -3,13 +3,13 @@ from datetime import datetime import pytest -from tests.integration.conftest import requires_playwright_auth from psrt_ghsa_bot.polyfills import ( GitHubPlaywrightClient, get_ghsa_comments, post_ghsa_comment, ) +from tests.conftest import requires_playwright_auth @requires_playwright_auth diff --git a/tests/integration/test_playwright.py b/tests/integration/test_playwright.py index 377f869..2c71478 100644 --- a/tests/integration/test_playwright.py +++ b/tests/integration/test_playwright.py @@ -3,9 +3,9 @@ from pathlib import Path import pytest -from tests.integration.conftest import requires_playwright_auth from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +from tests.conftest import requires_playwright_auth def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: From 14e3dc3622a6c7d1f5895ff727be7094ed9e8119 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:39:30 -0600 Subject: [PATCH 52/64] elevate to globals --- src/psrt_ghsa_bot/_monitoring.py | 9 ++++++--- src/psrt_ghsa_bot/config.py | 8 +++++++- src/psrt_ghsa_bot/health_check.py | 13 ++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/psrt_ghsa_bot/_monitoring.py b/src/psrt_ghsa_bot/_monitoring.py index d224162..a55ce74 100644 --- a/src/psrt_ghsa_bot/_monitoring.py +++ b/src/psrt_ghsa_bot/_monitoring.py @@ -1,11 +1,14 @@ """Sentry cron monitoring integration for GH act workflows.""" import os -from typing import Literal +from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk import crons +if TYPE_CHECKING: + from psrt_ghsa_bot.config import CheckinStatus + def init_sentry() -> None: """Initialize Sentry SDK with DSN from envvars.""" @@ -21,14 +24,14 @@ def init_sentry() -> None: def capture_checkin( monitor_slug: str, - status: Literal["in_progress", "ok", "error"], + status: CheckinStatus, duration: float | None = None, ) -> str | None: """Capture a Sentry cron check-in. Args: monitor_slug: The unique identifier for this monitor (e.g., "psrt-ghsa-cron") - status: The status of the check-in ("in_progress", "ok", or "error") + status: The status of the check-in (STATUS_IN_PROGRESS, STATUS_OK, or STATUS_ERROR) duration: Optional duration in seconds for the job execution Returns: diff --git a/src/psrt_ghsa_bot/config.py b/src/psrt_ghsa_bot/config.py index f4059b3..2c22b2b 100644 --- a/src/psrt_ghsa_bot/config.py +++ b/src/psrt_ghsa_bot/config.py @@ -1,7 +1,13 @@ """Common configuration for the PSRT GHSA Bot.""" -from typing import Final +from typing import Final, Literal + +type CheckinStatus = Literal["in_progress", "ok", "error"] MONITOR_SLUG_HEALTH: Final[str] = "psrt-health-monitor" MONITOR_SLUG_GHSA: Final[str] = "psrt-ghsa-cron" MONITOR_SLUG_PLAYWRIGHT: Final[str] = "psrt-playwright-cron" + +STATUS_IN_PROGRESS: Final[CheckinStatus] = "in_progress" +STATUS_OK: Final[CheckinStatus] = "ok" +STATUS_ERROR: Final[CheckinStatus] = "error" diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py index 1943af3..f33809a 100644 --- a/src/psrt_ghsa_bot/health_check.py +++ b/src/psrt_ghsa_bot/health_check.py @@ -14,6 +14,9 @@ MONITOR_SLUG_GHSA, MONITOR_SLUG_HEALTH, MONITOR_SLUG_PLAYWRIGHT, + STATUS_ERROR, + STATUS_IN_PROGRESS, + STATUS_OK, ) logger = logging.getLogger(__name__) @@ -27,7 +30,7 @@ def check_workflow_health() -> None: """Check the health of configured workflows and report to Sentry.""" init_sentry() - capture_checkin(MONITOR_SLUG_HEALTH, "in_progress") + capture_checkin(MONITOR_SLUG_HEALTH, STATUS_IN_PROGRESS) workflow_statuses = {workflow["file"]: False for workflow in WORKFLOWS_TO_CHECK} for workflow in WORKFLOWS_TO_CHECK: @@ -77,19 +80,19 @@ def check_workflow_health() -> None: if conclusion in ["failure", "timed_out", "cancelled"]: logger.error("Workflow failed with status: %s", conclusion) report_workflow_failure(workflow["file"], str(run_id), conclusion) - capture_checkin(workflow["monitor_slug"], "error") + capture_checkin(workflow["monitor_slug"], STATUS_ERROR) elif conclusion == "success": logger.info("Workflow succeeded") - capture_checkin(workflow["monitor_slug"], "ok") + capture_checkin(workflow["monitor_slug"], STATUS_OK) workflow_statuses[workflow["file"]] = True else: logger.warning("Unexpected conclusion: %s", conclusion) if all(workflow_statuses.values()): - capture_checkin(MONITOR_SLUG_HEALTH, "ok") + capture_checkin(MONITOR_SLUG_HEALTH, STATUS_OK) logger.info("All workflows healthy") else: - capture_checkin(MONITOR_SLUG_HEALTH, "error") + capture_checkin(MONITOR_SLUG_HEALTH, STATUS_ERROR) logger.error("Some workflows are unhealthy") sys.exit(1) From a5c7463ce0cb603bfaa1a21317c04a024dd1b5bf Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 11:54:39 -0600 Subject: [PATCH 53/64] make tests pass --- src/psrt_ghsa_bot/polyfills/playwright_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/polyfills/playwright_base.py b/src/psrt_ghsa_bot/polyfills/playwright_base.py index 0313fd0..c0f4711 100644 --- a/src/psrt_ghsa_bot/polyfills/playwright_base.py +++ b/src/psrt_ghsa_bot/polyfills/playwright_base.py @@ -52,7 +52,7 @@ def __init__( ) self.slow_mo = slow_mo self.record_video = record_video - self.username = os.environ["GH_BOT_USERNAME"] + self.username = os.getenv("GH_BOT_USERNAME") self._playwright: Playwright | None = None self._browser: Browser | None = None From 16444acf025419b2224a1ae507e76053be847be4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 14:13:38 -0600 Subject: [PATCH 54/64] Add bot commands --- src/psrt_ghsa_bot/commands/__init__.py | 14 + src/psrt_ghsa_bot/commands/authorization.py | 200 ++++++++++ src/psrt_ghsa_bot/commands/executor.py | 309 +++++++++++++++ src/psrt_ghsa_bot/commands/parser.py | 227 +++++++++++ src/psrt_ghsa_bot/comment_processor.py | 68 +++- src/psrt_ghsa_bot/state.py | 109 +++--- state.json | 19 +- tests/test_commands.py | 401 ++++++++++++++++++++ tests/test_executor.py | 242 ++++++++++++ tests/unit/test_state.py | 149 +++++--- 10 files changed, 1600 insertions(+), 138 deletions(-) create mode 100644 src/psrt_ghsa_bot/commands/__init__.py create mode 100644 src/psrt_ghsa_bot/commands/authorization.py create mode 100644 src/psrt_ghsa_bot/commands/executor.py create mode 100644 src/psrt_ghsa_bot/commands/parser.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_executor.py diff --git a/src/psrt_ghsa_bot/commands/__init__.py b/src/psrt_ghsa_bot/commands/__init__.py new file mode 100644 index 0000000..ef83000 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/__init__.py @@ -0,0 +1,14 @@ +"""Command processing system for PSRT GHSA Bot.""" + +from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized +from psrt_ghsa_bot.commands.executor import CommandResult, execute_command +from psrt_ghsa_bot.commands.parser import Command, parse_command + +__all__ = [ + "AuthorizationResult", + "Command", + "CommandResult", + "execute_command", + "is_authorized", + "parse_command", +] diff --git a/src/psrt_ghsa_bot/commands/authorization.py b/src/psrt_ghsa_bot/commands/authorization.py new file mode 100644 index 0000000..d1fba4d --- /dev/null +++ b/src/psrt_ghsa_bot/commands/authorization.py @@ -0,0 +1,200 @@ +"""Authorization checks for command execution. + +Determines if a user is authorized to execute bot commands based on: +- GHSA collaborator status (?) +- Repository admin status +- python/psrt team membership TODO: make this configurable, and allow list of org/teams? + like, what if we want PSRT to be able to responds across all PSF repos? (psf, python, pycon, pypi?) + or maybe we just say "team is $TEAM, and this bot works in $ORG as long as you are member of + that $TEAM" so we leave the user mgmt to the org admins. yeah.. probably that. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from githubkit import GitHub + +from http import HTTPStatus + + +@dataclass +class AuthorizationResult: + """Result of authorization check.""" + + authorized: bool + """Whether the user is authorized""" + reason: str + """Human-readable reason for the decision""" + + +def is_authorized( + github: GitHub, + username: str, + owner: str, + repo: str, + ghsa_id: str, +) -> AuthorizationResult: + """Check if user is authorized to execute commands. + + Authorization hierarchy: + 1. Members of python/psrt team + 2. GHSA collaborators + 3. Repository admins + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + AuthorizationResult indicating if user is authorized and a rason + """ + if _is_psrt_team_member(github, username): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is a member of python/psrt team", + ) + + if _is_ghsa_collaborator(github, username, owner, repo, ghsa_id): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is a collaborator on this advisory", + ) + + if _is_repo_admin(github, username, owner, repo): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is an admin of {owner}/{repo}", + ) + + return AuthorizationResult( + authorized=False, + reason=f"User {username} is not authorized to execute commands", + ) + + +def _is_psrt_team_member(github: GitHub, username: str) -> bool: + """Check if user is member of python/psrt team. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + + Returns: + True if user is a member of the python/psrt team + """ + try: + response = github.rest.teams.get_member_in_org( + org="python", + team_slug="psrt", + username=username, + ) + except Exception: + return False + else: + return response.status_code == HTTPStatus.NO_CONTENT + + +def _is_ghsa_collaborator( + github: GitHub, + username: str, + owner: str, + repo: str, + ghsa_id: str, +) -> bool: + """Check if user is collaborator on the GHSA. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + True if user is a collaborator on the advisory + """ + try: + advisory = github.rest.security_advisories.get_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + ) + + if not advisory.parsed_data: + return False + + collaborators = advisory.parsed_data.collaborating_users or [] + teams = advisory.parsed_data.collaborating_teams or [] + + for collaborator in collaborators: + if collaborator.login and collaborator.login.lower() == username.lower(): + return True + + return any(team.slug and _is_team_member(github, owner, team.slug, username) for team in teams) + except Exception: + return False + + +def _is_team_member( + github: GitHub, + org: str, + team_slug: str, + username: str, +) -> bool: + """Check if user is member of a specific team. + + Args: + github: Authenticated GitHub client + org: Organization name + team_slug: Team slug + username: GitHub username to check + + Returns: + True if user is a member of the team + """ + try: + response = github.rest.teams.get_member_in_org( + org=org, + team_slug=team_slug, + username=username, + ) + except Exception: + return False + else: + return response.status_code == HTTPStatus.NO_CONTENT + + +def _is_repo_admin( + github: GitHub, + username: str, + owner: str, + repo: str, +) -> bool: + """Check if user has admin permissions on repository. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + + Returns: + True if user is a repository admin + """ + try: + response = github.rest.repos.get_collaborator_permission_level( + owner=owner, + repo=repo, + username=username, + ) + + if not response.parsed_data or not response.parsed_data.permission: + return False + except Exception: + return False + else: + return response.parsed_data.permission == "admin" diff --git a/src/psrt_ghsa_bot/commands/executor.py b/src/psrt_ghsa_bot/commands/executor.py new file mode 100644 index 0000000..135df3a --- /dev/null +++ b/src/psrt_ghsa_bot/commands/executor.py @@ -0,0 +1,309 @@ +"""Command execution engine for PSRT GHSA Bot. + +TODO: Maybe we should look into easily extensiblke commands + like how discord.py or others do it so it can autodiscover + cmds and register them and keep this file just for the + executor, auth, results, and parser... +""" + +import os +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +from cvelib.cve_api import CveApi + +from psrt_ghsa_bot.app import reserve_one_cve +from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized +from psrt_ghsa_bot.commands.parser import Command, get_help_text, get_unknown_command_response + +if TYPE_CHECKING: + from githubkit import GitHub + + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + + +@dataclass +class CommandResult: + """Result of command execution.""" + + success: bool + """Whether the command executed successfully""" + message: str + """Response message to post as comment""" + error: Exception | None = None + """Exception if command failed""" + + +def execute_command( + cmd: Command, + github: GitHub, + playwright_client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, +) -> CommandResult: + """Execute a parsed command. + + Example: "@ assign-cve" + + These are based on src/psrt_ghsa_bot/commands/parser.py:AVAILABLE_COMMANDS + and do an auth check before trying. + + Args: + cmd: Parsed command to execute + github: Authenticated GitHub API client + playwright_client: Playwright client for UI automation + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with success status and response message + """ + # TODO: i dont like this.. it will just grow and grow... + auth_result = is_authorized(github, cmd.author, owner, repo, ghsa_id) + + if not auth_result.authorized: + return _unauthorized_result(cmd, auth_result) + + if cmd.action == "help": + return _handle_help(playwright_client) + + if cmd.action == "status": + return _handle_status(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "reject": + return _handle_reject(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "assign-cve": + return _handle_assign_cve(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "publish": + return _handle_publish(cmd, github, owner, repo, ghsa_id) + + return CommandResult( + success=False, + message=get_unknown_command_response(cmd.action, playwright_client.username), + ) + + +def _unauthorized_result(cmd: Command, auth_result: AuthorizationResult) -> CommandResult: + """Generate result for unauthorized command attempt. + + Args: + cmd: The command that was attempted + auth_result: Authorization check result + + Returns: + CommandResult with unauthorized message + """ + message = ( + f"❌ **Unauthorized**\n\n" + f"@{cmd.author}, you are not authorized to execute bot commands.\n\n" + f"**Reason:** {auth_result.reason}\n\n" + f"Only members of the `python/psrt` team and advisory collaborators can use bot commands." + ) + + return CommandResult(success=False, message=message) + + +def _handle_help(playwright_client: GitHubPlaywrightClient) -> CommandResult: + """Handle help command. + + Args: + playwright_client: Playwright client (for bot username) + + Returns: + CommandResult with help text + """ + help_text = get_help_text(playwright_client.username) + return CommandResult(success=True, message=help_text) + + +def _handle_status(_cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle status command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with status information + """ + try: + advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + + state = advisory.parsed_data.state + cve_id = advisory.parsed_data.cve_id or "None assigned" + created_at = advisory.parsed_data.created_at + updated_at = advisory.parsed_data.updated_at + created = datetime.fromisoformat(created_at) + days_old = (datetime.now(created.tzinfo) - created).days + + message = ( + f"📊 **Advisory Status**\n\n" + f"**Repository:** {owner}/{repo}\n" + f"**Advisory:** {ghsa_id}\n" + f"**State:** {state}\n" + f"**CVE ID:** {cve_id}\n" + f"**Created:** {days_old} days ago\n" + f"**Last Updated:** {updated_at}" + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"❌ **Error:** Failed to get advisory status: {e!s}", + error=e, + ) + + +def _handle_reject(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle CVE rejection command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with rejection confirmation + """ + if not cmd.arguments: + return CommandResult( + success=False, + message="❌ **Error:** Missing CVE ID\n\nUsage: `reject `\n\nExample: `reject CVE-2024-1234`", + ) + + cve_id = cmd.arguments[0] + + try: + advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + + current_cve = advisory.parsed_data.cve_id + + if current_cve is None: + return CommandResult( + success=False, + message=f"❌ **Error:** Advisory {ghsa_id} has no CVE ID assigned.", + ) + + if current_cve != cve_id: + return CommandResult( + success=False, + message=( + f"❌ **Error:** CVE ID mismatch\n\n" + f"Advisory {ghsa_id} is associated with {current_cve}, not {cve_id}." + ), + ) + + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"cve_id": None}, + ) + + message = ( + f"đŸšĢ **CVE Rejected**\n\n" + f"**Advisory:** {ghsa_id}\n" + f"**CVE ID:** {cve_id}\n\n" + f"The CVE ID has been removed from this advisory.\n\n" + f"_Note: This does not withdraw the CVE from the CVE system. " + f"CVE rejection via API requires additional CVE API integration._" + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"❌ **Error:** Failed to reject CVE: {e!s}", + error=e, + ) + + +def _handle_assign_cve(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle CVE assignment command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with assignment confirmation + """ + try: + advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + current_cve = advisory.parsed_data.cve_id + + if current_cve is not None: + return CommandResult( + success=False, + message=( + f"**CVE Already Assigned**\n\n" + f"Advisory {ghsa_id} already has CVE ID {current_cve} assigned.\n\n" + f"Use `status` to view advisory details or `reject {current_cve}` to remove it." + ), + ) + + cve_api = CveApi( + org="PSF", + username=os.environ["CVE_USERNAME"], + api_key=os.environ["CVE_API_KEY"], + env=os.environ.get("CVE_ENV", "prod"), + ) + + cve_id = reserve_one_cve(cve_api) + + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"cve_id": cve_id}, + ) + + message = ( + f"🔖 **CVE Assigned**\n\n" + f"**Advisory:** {ghsa_id}\n" + f"**CVE ID:** {cve_id}\n\n" + f"A new CVE ID has been reserved and associated with this advisory." + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"❌ **Error:** Failed to assign CVE: {e!s}", + error=e, + ) + + +def _handle_publish(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle advisory publication command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with publication confirmation + """ + message = "đŸ“ĸ **Publish Advisory** (Stub)\n\nbut for now I am just a stub cmd :)" + + return CommandResult(success=True, message=message) diff --git a/src/psrt_ghsa_bot/commands/parser.py b/src/psrt_ghsa_bot/commands/parser.py new file mode 100644 index 0000000..5378ad2 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/parser.py @@ -0,0 +1,227 @@ +"""Command parser for extracting bot commands from GHSA comments.""" + +import os +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TypedDict + + +class CommandInfo(TypedDict, total=False): + """Type definition for command metadata because type checke rhates me.""" + + description: str + usage: str + example: str + aliases: list[str] + + +@dataclass +class Command: + """Represents a parsed command from a GHSA comment.""" + + action: str + """The command action (e.g., 'help', 'reject', 'assign-cve')""" + arguments: list[str] + """List of arguments provided to the command""" + author: str + """GitHub username who issued the command""" + comment_id: str + """ID of the comment containing the command""" + timestamp: datetime + """When the command was issued""" + + def __repr__(self) -> str: + """String repr for debugs.""" + args_str = " ".join(self.arguments) if self.arguments else "(no args)" + return f"Command({self.action} {args_str} by {self.author})" + + +AVAILABLE_COMMANDS: dict[str, CommandInfo] = { + "help": { + "description": "Show this help message with all available commands", + "usage": "help", + "example": "help", + }, + "reject": { + "description": "Reject/withdraw a CVE ID for this advisory", + "usage": "reject ", + "example": "reject CVE-2024-1234", + "aliases": ["withdraw"], + }, + "assign-cve": { + "description": "Request CVE ID assignment for this advisory", + "usage": "assign-cve", + "example": "assign-cve", + "aliases": ["request-cve"], + }, + "status": { + "description": "Show current status of this advisory and associated CVE", + "usage": "status", + "example": "status", + }, + "publish": { + "description": "Publish this advisory and associated CVE ID", + "usage": "publish", + "example": "publish", + "aliases": ["release", "complete"], + }, +} + +COMMAND_ALIASES = { + "withdraw": "reject", + "request-cve": "assign-cve", + "release": "publish", + "complete": "publish", +} + + +def _build_command_pattern(bot_username: str) -> re.Pattern[str]: + """Build regex pattern for matching bot commands. + + Args: + bot_username: The bot's GitHub username.. gotten from env var. + + Returns: + Compiled regex pattern that matches @ [args] + """ + escaped_username = re.escape(bot_username) + return re.compile( + rf"@{escaped_username}\s+(\S+)(?:\s+(.+))?", + re.IGNORECASE | re.MULTILINE, + ) + + +def parse_command( + comment_body: str | None, + author: str, + comment_id: str, + bot_username: str, + timestamp: datetime | None = None, +) -> Command | None: + """Parse a command from a comment body. + + Looks for pattern: @ [arguments...] + + Args: + comment_body: The full text of the comment + author: GitHub username who wrote the comment + comment_id: Unique identifier for the comment + bot_username: GitHub username of the bot to look for + timestamp: When the comment was created (defaults to now) + + Returns: + Parsed Command object, or None if no valid command found + + Example: + >>> parse_command( + ... "@ reject CVE-2024-1234", + ... "JacobCoffee", + ... "comment-123", + ... "" + ... ) + Command(reject CVE-2024-1234 by JacobCoffee) + """ + if not comment_body: + return None + + pattern = _build_command_pattern(bot_username) + match = pattern.search(comment_body) + if not match: + return None + + action = match.group(1).lower() + action = COMMAND_ALIASES.get(action, action) + + arguments_str = match.group(2) + arguments = arguments_str.split() if arguments_str else [] + + if timestamp is None: + timestamp = datetime.now(tz=UTC) + + return Command( + action=action, + arguments=arguments, + author=author, + comment_id=comment_id, + timestamp=timestamp, + ) + + +def is_valid_command(action: str) -> bool: + """Check if an action is a recognized command. + + Args: + action: The command action to validate + + Returns: + True if the action is recognized, False otherwise + """ + return action.lower() in AVAILABLE_COMMANDS + + +def get_help_text(bot_username: str | None = None) -> str: + """Generate help text listing all available commands. + + Args: + bot_username: The bot's GitHub username to use in examples. + Defaults to GH_BOT_USERNAME environment variable. + + Returns: + Formatted markdown help text + """ + lines = [ + "# PSRT GHSA Bot Commands", + "", + "Available commands:", + "", + ] + + for cmd_info in AVAILABLE_COMMANDS.values(): + usage = f"@{bot_username} {cmd_info['usage']}" + example = f"@{bot_username} {cmd_info['example']}" + + lines.append(f"### `{usage}`") + lines.append(cmd_info["description"]) + lines.append(f"**Example:** `{example}`") + + if "aliases" in cmd_info: + aliases = ", ".join(f"`{alias}`" for alias in cmd_info["aliases"]) + lines.append(f"**Aliases:** {aliases}") + + lines.append("") + + lines.extend( + [ + "--", + "", + f"_To use a command, mention `@{bot_username}` followed by the command name and any required arguments._", + "", + "_Only members of the `python/psrt` team and advisory collaborators can execute commands._", + ] + ) + + return "\n".join(lines) + + +def get_unknown_command_response(action: str, bot_username: str | None = None) -> str: + """Generate response message for unknown commands. + + Args: + action: The unrecognized command action + bot_username: The bot's GitHub username to use in help message. + Defaults to GH_BOT_USERNAME environment variable. + + Returns: + Formatted error message with help text + """ + if bot_username is None: + bot_username = os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") + + available = ", ".join(f"`{cmd}`" for cmd in AVAILABLE_COMMANDS) + + return ( + f"❌ Unknown command: `{action}`\n\n" + f"Available commands: {available}\n\n" + f"Use `@{bot_username} help` for detailed usage information." + ) diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py index bae4453..dc4af0b 100644 --- a/src/psrt_ghsa_bot/comment_processor.py +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -1,6 +1,7 @@ """Comment processing service for PSRT GHSA Bot.""" import base64 +import contextlib import logging import os from dataclasses import dataclass @@ -9,7 +10,9 @@ from githubkit import AppAuthStrategy, GitHub from psrt_ghsa_bot.app import get_repository_advisories -from psrt_ghsa_bot.polyfills.comments import get_ghsa_comments +from psrt_ghsa_bot.commands.executor import execute_command +from psrt_ghsa_bot.commands.parser import parse_command +from psrt_ghsa_bot.polyfills.comments import get_ghsa_comments, post_ghsa_comment from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient from psrt_ghsa_bot.state import StateManager @@ -24,26 +27,35 @@ class CommentProcessingStats: ghsas_checked: int = 0 comments_found: int = 0 + commands_found: int = 0 + commands_executed: int = 0 + commands_skipped: int = 0 errors: int = 0 def process_ghsa_comments( + github: GitHub, playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, owner: str, repo: str, ghsa_id: str, ) -> int: - """Read comments for a single GHSA. + """Process comments for a single GHSA. Args: + github: Authenticated GitHub API client playwright_client: Playwright client for UI automation + state_manager: State manager for tracking processed commands owner: Repository owner repo: Repository name ghsa_id: GHSA identifier Returns: - Number of comments found + Number of commands executed """ + ghsa_key = f"{owner}/{repo}/{ghsa_id}" + try: comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id) except PermissionError as e: @@ -57,8 +69,46 @@ def process_ghsa_comments( logger.debug("No comments found on %s", ghsa_id) return 0 - logger.info("Found %d comments on %s", len(comments), ghsa_id) - return len(comments) + logger.info("Processing %d comments on %s", len(comments), ghsa_id) + commands_executed = 0 + for comment in comments: + comment_id = comment.id + author = comment.author + body = comment.body + + if author == playwright_client.username: + logger.debug("Skipping bot's own comment: %s", comment_id) + continue + + cmd = parse_command(body, author, comment_id, playwright_client.username, comment.created_at) + + if cmd is None: + logger.debug("No command in comment from @%s", author) + continue + + if not state_manager.should_process_comment(ghsa_key, comment_id, comment.created_at): + logger.debug("Command already processed: %s from @%s", cmd.action, author) + continue + + logger.info("Executing command: %s from @%s on %s", cmd.action, author, ghsa_id) + try: + result = execute_command(cmd, github, playwright_client, owner, repo, ghsa_id) + post_ghsa_comment(playwright_client, owner, repo, ghsa_id, result.message) + state_manager.mark_command_processed(ghsa_key, comment_id) + commands_executed += 1 + logger.info("Command executed successfully: %s", cmd.action) + except Exception: + logger.exception("Command execution failed: %s from @%s on %s", cmd.action, author, ghsa_id) + error_message = ( + f"❌ **Command Execution Failed**\n\n" + f"@{author}, an error occurred while processing your command.\n\n" + f"Please contact the PSRT team if this error persists." + ) + + with contextlib.suppress(Exception): + post_ghsa_comment(playwright_client, owner, repo, ghsa_id, error_message) + + return commands_executed def process_all_comments( @@ -112,13 +162,15 @@ def process_all_comments( stats.ghsas_checked += 1 logger.info("Checking GHSA: %s/%s/%s (state: %s)", owner, repo_name, ghsa_id, state_str) try: - comments_found = process_ghsa_comments( + commands_executed = process_ghsa_comments( + installation_github, playwright_client, + state_manager, owner, repo_name, ghsa_id, ) - stats.comments_found += comments_found + stats.commands_executed += commands_executed except Exception: logger.exception("Error processing %s", ghsa_id) stats.errors += 1 @@ -155,7 +207,7 @@ def main() -> None: logger.info("=" * 50) logger.info("Processing Summary:") logger.info(" GHSAs checked: %d", stats.ghsas_checked) - logger.info(" Comments found: %d", stats.comments_found) + logger.info(" Commands executed: %d", stats.commands_executed) logger.info(" Errors: %d", stats.errors) logger.info("=" * 50) diff --git a/src/psrt_ghsa_bot/state.py b/src/psrt_ghsa_bot/state.py index be16fe6..994621d 100644 --- a/src/psrt_ghsa_bot/state.py +++ b/src/psrt_ghsa_bot/state.py @@ -1,26 +1,16 @@ """State management for tracking processed comments and commands. -We don't continuosly run the playwright process, we run it periodiclly in GHA. -So, we need a state tracking to keep track of the last time we ran the GHA -so we only process comments for GHSA things that have been created AT or -AFTER the last tiem we ran. - -We could do a simple file based thing touching an epoch a reading it -but this tries to rely on gha cache to make it a little faster. - -- we store the state in a file in the cache directory -- we use the cache directory to store the state file -- state file contains json obj with state including: - - date/time we last ran - - last comment id we processed - - set of commands we've processed - - count of commands processed -- we use the state file to determine what to process afterwards -- update stat efile AFTER processing this new set -- 🔁 +We don't continuously run the playwright process, we run it periodically in GHA. +So, we need state tracking to keep track of the last time we ran so we only +process comments that have been created AFTER the last time we ran. + +Instead of storing all processed command hashes (which grows unbounded), +we use timestamp-based filtering: +- Store `last_processed_at` per GHSA +- Only process comments newer than this timestamp +- For replay capability, add comment IDs to `commands_to_reprocess` """ -import hashlib import json from dataclasses import dataclass, field from datetime import UTC, datetime @@ -35,17 +25,13 @@ class GHSAState: """State for a single GHSA.""" - last_comment_id: str | None = None last_processed_at: str | None = None - processed_commands: set[str] = field(default_factory=set) commands_processed_count: int = 0 def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dict.""" return { - "last_comment_id": self.last_comment_id, "last_processed_at": self.last_processed_at, - "processed_commands": list(self.processed_commands), "commands_processed_count": self.commands_processed_count, } @@ -53,9 +39,7 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: Mapping[str, Any]) -> GHSAState: """Creat from dict.""" return cls( - last_comment_id=data.get("last_comment_id"), last_processed_at=data.get("last_processed_at"), - processed_commands=set(data.get("processed_commands", [])), commands_processed_count=data.get("commands_processed_count", 0), ) @@ -66,12 +50,14 @@ class BotState: last_run: str | None = None ghsas: dict[str, GHSAState] = field(default_factory=dict) + commands_to_reprocess: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dict.""" return { "last_run": self.last_run, "ghsas": {key: state.to_dict() for key, state in self.ghsas.items()}, + "commands_to_reprocess": self.commands_to_reprocess, } @classmethod @@ -80,6 +66,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> BotState: return cls( last_run=data.get("last_run"), ghsas={key: GHSAState.from_dict(value) for key, value in data.get("ghsas", {}).items()}, + commands_to_reprocess=data.get("commands_to_reprocess", []), ) @@ -137,62 +124,70 @@ def get_ghsa_state(self, ghsa_id: str) -> GHSAState: state.ghsas[ghsa_id] = GHSAState() return state.ghsas[ghsa_id] - def is_command_processed(self, ghsa_id: str, comment_id: str, command_text: str, author: str) -> bool: - """Check if a command has been processed. - - TODO: so, if someone edits their comment will it change the hasH? + def should_process_comment(self, ghsa_id: str, comment_id: str, comment_created_at: datetime) -> bool: + """Check if a comment should be processed based on timestamp. Args: ghsa_id: GHSA identifier comment_id: GitHub comment ID - command_text: Raw command text - author: Comment author username + comment_created_at: When the comment was created Returns: - True if command was already processed + True if comment should be processed (newer than last run or in reprocess list) """ + state = self.load() + + if comment_id in state.commands_to_reprocess: + return True + ghsa_state = self.get_ghsa_state(ghsa_id) - command_hash = self._hash_command(comment_id, command_text, author) - return command_hash in ghsa_state.processed_commands + if ghsa_state.last_processed_at is None: + return True + + last_processed = datetime.fromisoformat(ghsa_state.last_processed_at) + return comment_created_at > last_processed - def mark_command_processed(self, ghsa_id: str, comment_id: str, command_text: str, author: str) -> None: + def mark_command_processed(self, ghsa_id: str, comment_id: str) -> None: """Mark a command as processed. Args: ghsa_id: GHSA identifier comment_id: GitHub comment ID - command_text: Raw command text - author: Comment author username """ + state = self.load() ghsa_state = self.get_ghsa_state(ghsa_id) - command_hash = self._hash_command(comment_id, command_text, author) - ghsa_state.processed_commands.add(command_hash) ghsa_state.commands_processed_count += 1 - ghsa_state.last_comment_id = comment_id ghsa_state.last_processed_at = datetime.now(UTC).isoformat() - def _hash_command(self, comment_id: str, command_text: str, author: str) -> str: - """Generate unique hash for a command. - - Args: - comment_id: GitHub comment ID - command_text: Raw command text - author: Comment author username - - Returns: - SHA-256 hash of command components - """ - content = f"{comment_id}:{command_text}:{author}" - return hashlib.sha256(content.encode()).hexdigest()[:16] + if comment_id in state.commands_to_reprocess: + state.commands_to_reprocess.remove(comment_id) - def update_ghsa_state(self, ghsa_id: str, last_comment_id: str | None = None) -> None: + def update_ghsa_state(self, ghsa_id: str) -> None: """Update state for a GHSA after processing. Args: ghsa_id: GHSA identifier - last_comment_id: Last processed comment ID """ ghsa_state = self.get_ghsa_state(ghsa_id) - if last_comment_id: - ghsa_state.last_comment_id = last_comment_id ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + def add_command_to_reprocess(self, comment_id: str) -> None: + """Add a comment ID to the reprocess list. + + This allows replaying commands by adding their comment IDs + via a PR to the state file. + + Args: + comment_id: GitHub comment ID to reprocess + """ + state = self.load() + if comment_id not in state.commands_to_reprocess: + state.commands_to_reprocess.append(comment_id) + + def get_commands_to_reprocess(self) -> list[str]: + """Get list of comment IDs pending reprocessing. + + Returns: + List of comment IDs to reprocess + """ + return self.load().commands_to_reprocess.copy() diff --git a/state.json b/state.json index d030b6a..0f924f3 100644 --- a/state.json +++ b/state.json @@ -1,15 +1,14 @@ { - "last_run": "2025-11-20T00:20:10.935213+00:00", + "last_run": "2025-11-25T17:49:57.576176+00:00", "ghsas": { + "jolt-org/ghsa-testing/GHSA-j5pm-9w6r-h5rr": { + "last_processed_at": "2025-11-25T17:49:46.335109+00:00", + "commands_processed_count": 1 + }, "jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf": { - "last_comment_id": "comment-17", - "last_processed_at": "2025-11-19T21:12:48.590575+00:00", - "processed_commands": [ - "4d55106d50489f70", - "ed3d1e88521c7bc4", - "b47d14abe3060256" - ], - "commands_processed_count": 3 + "last_processed_at": "2025-11-25T17:49:57.575833+00:00", + "commands_processed_count": 1 } - } + }, + "commands_to_reprocess": [] } \ No newline at end of file diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..99c5b85 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,401 @@ +"""Tests for command parsing system. + +todo: test for auth checks to make sure they are handled properly +""" + +import os +from datetime import UTC, datetime + +import pytest + +from psrt_ghsa_bot.commands.parser import ( + AVAILABLE_COMMANDS, + Command, + get_help_text, + get_unknown_command_response, + is_valid_command, + parse_command, +) + + +@pytest.fixture +def bot_username() -> str: + """Get bot username from environment or use default.""" + return os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") + + +class TestCommandParsing: + """Test command parsing from comment text.""" + + def test_parse_simple_command(self) -> None: + """Test parsing a simple command without arguments.""" + result = parse_command( + "@psrt-ghsabot help", + "testuser", + "comment-1", + "psrt-ghsabot", + datetime(2024, 1, 1, 12, 0, 0), + ) + + assert result is not None + assert result.action == "help" + assert result.arguments == [] + assert result.author == "testuser" + assert result.comment_id == "comment-1" + assert result.timestamp == datetime(2024, 1, 1, 12, 0, 0) + + def test_parse_command_with_single_argument(self) -> None: + """Test parsing command with one argument.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-1234", + "maintainer", + "comment-2", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-1234"] + assert result.author == "maintainer" + + def test_parse_command_with_multiple_arguments(self) -> None: + """Test parsing command with multiple arguments.""" + result = parse_command( + "@psrt-ghsabot some-cmd arg1 arg2 arg3", + "user", + "comment-3", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "some-cmd" + assert result.arguments == ["arg1", "arg2", "arg3"] + + def test_parse_command_case_insensitive(self) -> None: + """Test that bot mention and command are case-insensitive.""" + variations = [ + "@PSRT-GHSABOT help", + "@Psrt-GhsaBot help", + "@psrt-ghsabot HELP", + "@psrt-ghsabot Help", + ] + + for text in variations: + result = parse_command(text, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "help" + + def test_parse_command_in_middle_of_text(self) -> None: + """Test parsing command embedded in larger comment.""" + comment = """ + I think we should handle this differently. + + @psrt-ghsabot reject CVE-2024-5678 + + Let me know if you agree. + """ + + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-5678"] + + def test_parse_command_with_extra_whitespace(self) -> None: + """Test parsing handles extra whitespace correctly.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-9999", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-9999"] + + def test_parse_command_with_alias(self) -> None: + """Test that command aliases work correctly.""" + result = parse_command( + "@psrt-ghsabot withdraw CVE-2024-1111", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-1111"] + + def test_parse_publish_command(self) -> None: + """Test parsing publish command.""" + result = parse_command( + "@psrt-ghsabot publish", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "publish" + assert result.arguments == [] + + def test_parse_publish_aliases(self) -> None: + """Test that publish aliases work correctly.""" + for alias in ["release", "complete"]: + result = parse_command( + f"@psrt-ghsabot {alias}", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "publish" + + def test_parse_no_command(self) -> None: + """Test that None is returned when no command is found.""" + result = parse_command( + "Just a regular comment without bot mention", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is None + + def test_parse_incomplete_mention(self) -> None: + """Test that incomplete mentions don't parse.""" + result = parse_command("@psrt", "user", "comment", "psrt-ghsabot") + assert result is None + + result = parse_command("psrt-ghsabot help", "user", "comment", "psrt-ghsabot") + assert result is None + + def test_parse_empty_comment(self) -> None: + """Test parsing empty or None comment.""" + assert parse_command("", "user", "comment", "psrt-ghsabot") is None + assert parse_command(None, "user", "comment", "psrt-ghsabot") is None + + def test_parse_command_default_timestamp(self) -> None: + """Test that timestamp defaults to current time.""" + before = datetime.now(tz=UTC) + result = parse_command("@psrt-ghsabot help", "user", "comment", "psrt-ghsabot") + after = datetime.now(tz=UTC) + + assert result is not None + assert before <= result.timestamp <= after + + def test_parse_command_custom_bot_username(self) -> None: + """Test parsing with custom bot username.""" + result = parse_command( + "@my-custom-bot reject CVE-2024-9999", + "user", + "comment", + "my-custom-bot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-9999"] + + def test_parse_command_username_with_special_chars(self) -> None: + """Test parsing bot username with regex special characters.""" + result = parse_command( + "@bot.test+dev reject CVE-2024-9999", + "user", + "comment", + "bot.test+dev", + ) + + assert result is not None + assert result.action == "reject" + + +class TestCommandValidation: + """Test command validation functions.""" + + def test_is_valid_command_recognized(self) -> None: + """Test that recognized commands are valid.""" + for cmd in AVAILABLE_COMMANDS: + assert is_valid_command(cmd) + + def test_is_valid_command_case_insensitive(self) -> None: + """Test validation is case-insensitive.""" + assert is_valid_command("HELP") + assert is_valid_command("Help") + assert is_valid_command("reject") + assert is_valid_command("REJECT") + + def test_is_valid_command_unrecognized(self) -> None: + """Test that unrecognized commands are invalid.""" + assert not is_valid_command("unknown") + assert not is_valid_command("foo") + assert not is_valid_command("delete-everything") + + def test_is_valid_command_publish(self) -> None: + """Test that publish command is recognized.""" + assert is_valid_command("publish") + assert is_valid_command("PUBLISH") + assert is_valid_command("Publish") + + +class TestHelpText: + """Test help text generation.""" + + def test_get_help_text_format(self) -> None: + """Test that help text is properly formatted.""" + help_text = get_help_text("psrt-ghsabot") + + assert "PSRT GHSA Bot Commands" in help_text + assert "Available commands:" in help_text + + for cmd_name, cmd_info in AVAILABLE_COMMANDS.items(): + assert cmd_name in help_text.lower() + assert cmd_info["description"] in help_text + assert f"@psrt-ghsabot {cmd_info['usage']}" in help_text + assert f"@psrt-ghsabot {cmd_info['example']}" in help_text + + def test_get_help_text_includes_all_commands(self) -> None: + """Test that all commands are documented in help.""" + help_text = get_help_text("psrt-ghsabot") + + for cmd_name in AVAILABLE_COMMANDS: + assert cmd_name in help_text.lower() + + def test_get_help_text_shows_aliases(self) -> None: + """Test that command aliases are shown in help.""" + help_text = get_help_text("psrt-ghsabot") + + assert "withdraw" in help_text + assert "request-cve" in help_text + assert "release" in help_text + assert "complete" in help_text + + def test_get_help_text_custom_bot_username(self) -> None: + """Test help text with custom bot username.""" + help_text = get_help_text("my-custom-bot") + + assert "@my-custom-bot help" in help_text + assert "@my-custom-bot reject" in help_text + assert "@my-custom-bot publish" in help_text + assert "@psrt-ghsabot" not in help_text + + +class TestUnknownCommandResponse: + """Test unknown command response generation.""" + + def test_get_unknown_command_response_includes_action(self) -> None: + """Test that response includes the unknown action.""" + response = get_unknown_command_response("foo") + + assert "foo" in response + assert "Unknown command" in response + + def test_get_unknown_command_response_lists_available(self) -> None: + """Test that response lists available commands.""" + response = get_unknown_command_response("invalid") + + for cmd in AVAILABLE_COMMANDS: + assert cmd in response.lower() + + def test_get_unknown_command_response_suggests_help(self, bot_username: str) -> None: + """Test that response suggests using help.""" + response = get_unknown_command_response("bad", bot_username) + + assert "help" in response.lower() + assert f"@{bot_username} help" in response + + +class TestCommandRepresentation: + """Test Command dataclass methods.""" + + def test_command_repr(self) -> None: + """Test Command __repr__ output.""" + cmd = Command( + action="reject", + arguments=["CVE-2024-1234"], + author="testuser", + comment_id="comment-1", + timestamp=datetime(2024, 1, 1), + ) + + repr_str = repr(cmd) + assert "reject" in repr_str + assert "CVE-2024-1234" in repr_str + assert "testuser" in repr_str + + def test_command_repr_no_arguments(self) -> None: + """Test Command __repr__ with no arguments.""" + cmd = Command( + action="help", + arguments=[], + author="user", + comment_id="comment", + timestamp=datetime.now(), + ) + + repr_str = repr(cmd) + assert "help" in repr_str + assert "(no args)" in repr_str + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_command_with_special_characters_in_args(self) -> None: + """Test parsing arguments with special characters.""" + result = parse_command( + "@psrt-ghsabot test arg-with-dash arg_with_underscore", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.arguments == ["arg-with-dash", "arg_with_underscore"] + + def test_multiple_bot_mentions(self) -> None: + """Test that only first mention is parsed.""" + comment = """ + @psrt-ghsabot help + + Actually, wait: + @psrt-ghsabot status + """ + + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "help" + + def test_command_at_start_of_line(self) -> None: + """Test command at beginning of comment.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-1234", + "user", + "comment", + "psrt-ghsabot", + ) + assert result is not None + + def test_command_at_end_of_line(self) -> None: + """Test command at end of comment.""" + result = parse_command( + "Here's my command: @psrt-ghsabot help", + "user", + "comment", + "psrt-ghsabot", + ) + assert result is not None + + def test_command_on_its_own_line(self) -> None: + """Test command on a line by itself.""" + comment = """ + Some context here. + + @psrt-ghsabot reject CVE-2024-1234 + + More context below. + """ + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "reject" diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..bf89637 --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,242 @@ +"""Tests for command execution.""" + +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from psrt_ghsa_bot.commands import CommandResult, execute_command +from psrt_ghsa_bot.commands.parser import Command + + +@pytest.fixture +def mock_github(): + """Create mock GitHub client.""" + return Mock() + + +@pytest.fixture +def mock_playwright(): + """Create mock Playwright client.""" + mock = Mock() + mock.username = "test-bot" + return mock + + +@pytest.fixture +def sample_command(): + """Create sample command.""" + return Command( + action="help", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + +class TestCommandExecution: + """Test command execution.""" + + def test_execute_help_command(self, sample_command, mock_github, mock_playwright) -> None: + """Test executing help command.""" + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + sample_command, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert isinstance(result, CommandResult) + assert result.success + assert "PSRT GHSA Bot Commands" in result.message + assert "@test-bot help" in result.message + + def test_execute_status_command_stub(self, mock_github, mock_playwright) -> None: + """Test executing status command (stub).""" + cmd = Command( + action="status", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.parsed_data = Mock( + state="draft", + cve_id="CVE-2024-1234", + created_at="2024-01-01T00:00:00Z", + updated_at="2024-01-15T00:00:00Z", + ) + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "Advisory Status" in result.message + assert "GHSA-1234" in result.message + assert "draft" in result.message + + def test_execute_reject_command_with_cve(self, mock_github, mock_playwright) -> None: + """Test executing reject command with CVE ID.""" + cmd = Command( + action="reject", + arguments=["CVE-2024-1234"], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.parsed_data = Mock(cve_id="CVE-2024-1234") + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "CVE Rejected" in result.message + assert "CVE-2024-1234" in result.message + + def test_execute_reject_without_cve_fails(self, mock_github, mock_playwright) -> None: + """Test executing reject command without CVE ID fails.""" + cmd = Command( + action="reject", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Missing CVE ID" in result.message + + def test_execute_assign_cve_command(self, mock_github, mock_playwright) -> None: + """Test executing assign-cve command.""" + cmd = Command( + action="assign-cve", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.parsed_data = Mock(cve_id=None) + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + # Assign CVE requires CVE API credentials which won't be available in tests + # So we expect it to fail but still test it's calling the right handler + assert "assign" in result.message.lower() or "cve" in result.message.lower() + + def test_execute_publish_command(self, mock_github, mock_playwright) -> None: + """Test executing publish command.""" + cmd = Command( + action="publish", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "Publish Advisory" in result.message + + def test_execute_unknown_command(self, mock_github, mock_playwright) -> None: + """Test executing unknown command.""" + cmd = Command( + action="unknown-action", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Unknown command" in result.message + assert "unknown-action" in result.message + + def test_execute_unauthorized_user(self, sample_command, mock_github, mock_playwright) -> None: + """Test executing command as unauthorized user.""" + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=404) + mock_github.rest.security_advisories.get_repository_advisory.side_effect = Exception("Not found") + mock_github.rest.repos.get_collaborator_permission_level.side_effect = Exception("Not found") + + result = execute_command( + sample_command, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Unauthorized" in result.message + assert "testuser" in result.message diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 0a3715b..b11293d 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -1,6 +1,7 @@ """Tests for state management.""" import json +from datetime import UTC, datetime, timedelta from pathlib import Path from tempfile import TemporaryDirectory @@ -9,33 +10,25 @@ def test_ghsa_state_to_dict() -> None: state = GHSAState( - last_comment_id="comment-123", last_processed_at="2025-11-19T12:00:00Z", - processed_commands={"hash1", "hash2"}, commands_processed_count=2, ) data = state.to_dict() - assert data["last_comment_id"] == "comment-123" assert data["last_processed_at"] == "2025-11-19T12:00:00Z" - assert set(data["processed_commands"]) == {"hash1", "hash2"} assert data["commands_processed_count"] == 2 def test_ghsa_state_from_dict() -> None: data = { - "last_comment_id": "comment-123", "last_processed_at": "2025-11-19T12:00:00Z", - "processed_commands": ["hash1", "hash2"], "commands_processed_count": 2, } state = GHSAState.from_dict(data) - assert state.last_comment_id == "comment-123" assert state.last_processed_at == "2025-11-19T12:00:00Z" - assert state.processed_commands == {"hash1", "hash2"} assert state.commands_processed_count == 2 @@ -44,18 +37,19 @@ def test_bot_state_to_dict() -> None: last_run="2025-11-19T12:00:00Z", ghsas={ "python/cpython/GHSA-xxxx": GHSAState( - last_comment_id="comment-123", - processed_commands={"hash1"}, + last_processed_at="2025-11-19T11:00:00Z", commands_processed_count=1, ) }, + commands_to_reprocess=["comment-abc"], ) data = bot_state.to_dict() assert data["last_run"] == "2025-11-19T12:00:00Z" assert "python/cpython/GHSA-xxxx" in data["ghsas"] - assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_comment_id"] == "comment-123" + assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_processed_at"] == "2025-11-19T11:00:00Z" + assert data["commands_to_reprocess"] == ["comment-abc"] def test_bot_state_from_dict() -> None: @@ -63,19 +57,19 @@ def test_bot_state_from_dict() -> None: "last_run": "2025-11-19T12:00:00Z", "ghsas": { "python/cpython/GHSA-xxxx": { - "last_comment_id": "comment-123", - "last_processed_at": None, - "processed_commands": ["hash1"], + "last_processed_at": "2025-11-19T11:00:00Z", "commands_processed_count": 1, } }, + "commands_to_reprocess": ["comment-abc"], } bot_state = BotState.from_dict(data) assert bot_state.last_run == "2025-11-19T12:00:00Z" assert "python/cpython/GHSA-xxxx" in bot_state.ghsas - assert bot_state.ghsas["python/cpython/GHSA-xxxx"].last_comment_id == "comment-123" + assert bot_state.ghsas["python/cpython/GHSA-xxxx"].last_processed_at == "2025-11-19T11:00:00Z" + assert bot_state.commands_to_reprocess == ["comment-abc"] def test_state_manager_load_empty() -> None: @@ -87,6 +81,7 @@ def test_state_manager_load_empty() -> None: assert state.last_run is None assert len(state.ghsas) == 0 + assert state.commands_to_reprocess == [] def test_state_manager_save_and_load() -> None: @@ -95,7 +90,7 @@ def test_state_manager_save_and_load() -> None: manager = StateManager(state_file) state = manager.load() - state.ghsas["python/cpython/GHSA-xxxx"] = GHSAState(last_comment_id="comment-123") + state.ghsas["python/cpython/GHSA-xxxx"] = GHSAState(last_processed_at="2025-11-19T12:00:00Z") manager.save() @@ -103,7 +98,7 @@ def test_state_manager_save_and_load() -> None: data = json.load(f) assert "python/cpython/GHSA-xxxx" in data["ghsas"] - assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_comment_id"] == "comment-123" + assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_processed_at"] == "2025-11-19T12:00:00Z" def test_state_manager_get_ghsa_state() -> None: @@ -113,95 +108,123 @@ def test_state_manager_get_ghsa_state() -> None: ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") - assert ghsa_state.last_comment_id is None - assert len(ghsa_state.processed_commands) == 0 + assert ghsa_state.last_processed_at is None + assert ghsa_state.commands_processed_count == 0 -def test_state_manager_is_command_processed() -> None: +def test_state_manager_should_process_comment_new_ghsa() -> None: with TemporaryDirectory() as tmpdir: state_file = Path(tmpdir) / "state.json" manager = StateManager(state_file) - is_processed = manager.is_command_processed( + should_process = manager.should_process_comment( "python/cpython/GHSA-xxxx", "comment-123", - "@bot help", - "octocat", + datetime.now(UTC), ) - assert not is_processed + assert should_process -def test_state_manager_mark_command_processed() -> None: +def test_state_manager_should_process_comment_newer_than_last() -> None: with TemporaryDirectory() as tmpdir: state_file = Path(tmpdir) / "state.json" manager = StateManager(state_file) - manager.mark_command_processed( - "python/cpython/GHSA-xxxx", - "comment-123", - "@bot help", - "octocat", - ) + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + ghsa_state.last_processed_at = (datetime.now(UTC) - timedelta(hours=1)).isoformat() - is_processed = manager.is_command_processed( + should_process = manager.should_process_comment( "python/cpython/GHSA-xxxx", "comment-123", - "@bot help", - "octocat", + datetime.now(UTC), ) - assert is_processed + assert should_process -def test_state_manager_command_hash_different_for_different_inputs() -> None: +def test_state_manager_should_not_process_older_comment() -> None: with TemporaryDirectory() as tmpdir: state_file = Path(tmpdir) / "state.json" manager = StateManager(state_file) - manager.mark_command_processed( + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + old_comment_time = datetime.now(UTC) - timedelta(hours=1) + should_process = manager.should_process_comment( "python/cpython/GHSA-xxxx", "comment-123", - "@bot help", - "octocat", + old_comment_time, ) - is_processed_different_comment = manager.is_command_processed( - "python/cpython/GHSA-xxxx", - "comment-456", - "@bot help", - "octocat", - ) - assert not is_processed_different_comment + assert not should_process - is_processed_different_command = manager.is_command_processed( - "python/cpython/GHSA-xxxx", - "comment-123", - "@bot status", - "octocat", - ) - assert not is_processed_different_command - is_processed_different_author = manager.is_command_processed( +def test_state_manager_should_process_comment_in_reprocess_list() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + manager.add_command_to_reprocess("comment-123") + + old_comment_time = datetime.now(UTC) - timedelta(hours=1) + should_process = manager.should_process_comment( "python/cpython/GHSA-xxxx", "comment-123", - "@bot help", - "different-user", + old_comment_time, ) - assert not is_processed_different_author + assert should_process -def test_state_manager_update_ghsa_state() -> None: + +def test_state_manager_mark_command_processed() -> None: with TemporaryDirectory() as tmpdir: state_file = Path(tmpdir) / "state.json" manager = StateManager(state_file) - manager.update_ghsa_state( - "python/cpython/GHSA-xxxx", - last_comment_id="comment-999", - ) + manager.mark_command_processed("python/cpython/GHSA-xxxx", "comment-123") ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + assert ghsa_state.commands_processed_count == 1 + assert ghsa_state.last_processed_at is not None + + +def test_state_manager_mark_command_removes_from_reprocess() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.add_command_to_reprocess("comment-123") + assert "comment-123" in manager.get_commands_to_reprocess() + + manager.mark_command_processed("python/cpython/GHSA-xxxx", "comment-123") + + assert "comment-123" not in manager.get_commands_to_reprocess() + + +def test_state_manager_update_ghsa_state() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) - assert ghsa_state.last_comment_id == "comment-999" + manager.update_ghsa_state("python/cpython/GHSA-xxxx") + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") assert ghsa_state.last_processed_at is not None + + +def test_state_manager_add_command_to_reprocess() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.add_command_to_reprocess("comment-123") + manager.add_command_to_reprocess("comment-456") + manager.add_command_to_reprocess("comment-123") + + reprocess_list = manager.get_commands_to_reprocess() + assert reprocess_list == ["comment-123", "comment-456"] From e3f9782e95c57d4014f956f28038e9b04ae1695d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 14:52:21 -0600 Subject: [PATCH 55/64] =?UTF-8?q?Add=20=E2=80=9CAct=E2=80=9D=20for=20local?= =?UTF-8?q?=20GitHub=20Actions=20debugging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 1332326..10a118b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,10 @@ .DEFAULT_GOAL:=help .ONESHELL: +ACT_INSTALLED := $(shell command -v act 2> /dev/null) + +.PHONY: help upgrade lint fmt fmt-check type-check ty check test ci +.PHONY: act-check act-list act-ci act-health-check act-playwright act-cron +.PHONY: cron playwright health-check help: ## Display this help text for Makefile @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) @@ -32,13 +37,36 @@ test: ## Run tests ci: lint fmt type-check test ## Run everything +##@ GitHub Actions (Local Testing) + +act-check: ## Check if act is installed +ifndef ACT_INSTALLED + @echo "act is not installed. Install it from: https://nektosact.com/installation/index.html" + @exit 1 +endif + +act-list: act-check ## List all available GitHub Actions workflows + @act -l + +act-ci: act-check ## Test CI workflow locally using act + @act -W .github/workflows/ci.yml + +act-health-check: act-check ## Test health-check workflow locally using act + @act -W .github/workflows/health-check.yml + +act-playwright: act-check ## Test playwright workflow locally using act + @act -W .github/workflows/playwright.yml + +act-cron: act-check ## Test cron workflow locally using act + @act -W .github/workflows/cron.yml + ##@ Live Bot Commands ### These all require .env file with the vars set based on .env.example! -cron-run: ## Run the cron bot (app.py) +cron: ## Run the cron bot (app.py) @uv run python -m psrt_ghsa_bot.app -playwright-run: ## Run playwright bot +playwright: ## Run playwright bot @uv run python -m psrt_ghsa_bot.comment_processor health-check: ## Run health check From 76ff3a07bf271f704367d777c34b7a7dfa62960a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 15:07:56 -0600 Subject: [PATCH 56/64] fix bot author check --- src/psrt_ghsa_bot/polyfills/comments/get_comments.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py index 6020ec0..26133f8 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -6,6 +6,7 @@ """ import logging +import os from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -42,10 +43,10 @@ def __repr__(self) -> str: def get_ghsa_comments( - client: GitHubPlaywrightClient, - owner: str, - repo: str, - ghsa_id: str, + client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, ) -> list[GHSAComment]: """Get all comments from a GitHub Security Advisory using Playwright. @@ -305,7 +306,7 @@ def _is_bot_author(element: Locator, author: str) -> bool: Returns: True if author is a bot """ - if "bot" in author.lower(): + if author == os.environ.get("GH_BOT_USERNAME", "PSRT-GHSA-Automation"): return True try: From b8c82b3135cc8f0ee150c4b1e34a4d2ea964da13 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 15:08:43 -0600 Subject: [PATCH 57/64] fix default for bot uname --- src/psrt_ghsa_bot/commands/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/commands/parser.py b/src/psrt_ghsa_bot/commands/parser.py index 5378ad2..779188a 100644 --- a/src/psrt_ghsa_bot/commands/parser.py +++ b/src/psrt_ghsa_bot/commands/parser.py @@ -216,7 +216,7 @@ def get_unknown_command_response(action: str, bot_username: str | None = None) - Formatted error message with help text """ if bot_username is None: - bot_username = os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") + bot_username = os.environ.get("GH_BOT_USERNAME", "PSRT-GHSA-Automation") available = ", ".join(f"`{cmd}`" for cmd in AVAILABLE_COMMANDS) From e510788569c73a155a2741381118406d32048804 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 15:17:52 -0600 Subject: [PATCH 58/64] fix: raise error instead of returning fallback timestamp When timestamp extraction fails, raise ValueError instead of silently returning datetime.now(). This ensures scraping failures are surfaced rather than producing potentially incorrect data. Addresses review feedback from @sethmlarson. --- src/psrt_ghsa_bot/polyfills/comments/get_comments.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py index 26133f8..3230320 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -274,7 +274,10 @@ def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: timestamp_type: "created" or "updated" Returns: - Parsed datetime or current time as fallback + Parsed datetime from the element + + Raises: + ValueError: If timestamp cannot be extracted from any known selector """ timestamp_selectors = [ "relative-time", @@ -290,10 +293,10 @@ def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: if datetime_str: return datetime.fromisoformat(datetime_str) except Exception: - logger.exception("Failed to get timestamp with selector %s", selector) + logger.debug("Selector %s did not match for %s timestamp", selector, timestamp_type) continue - return datetime.now(tz=UTC) + raise ValueError(f"Could not extract {timestamp_type} timestamp from comment element") def _is_bot_author(element: Locator, author: str) -> bool: From a3e50f04ccdc24698e554da919f39171cbfdcb72 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 15:37:40 -0600 Subject: [PATCH 59/64] fmt --- .../polyfills/comments/get_comments.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py index 3230320..48719ef 100644 --- a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -8,7 +8,7 @@ import logging import os from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import datetime from typing import TYPE_CHECKING from playwright.sync_api import Locator @@ -43,10 +43,10 @@ def __repr__(self) -> str: def get_ghsa_comments( - client: GitHubPlaywrightClient, - owner: str, - repo: str, - ghsa_id: str, + client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, ) -> list[GHSAComment]: """Get all comments from a GitHub Security Advisory using Playwright. @@ -296,7 +296,8 @@ def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: logger.debug("Selector %s did not match for %s timestamp", selector, timestamp_type) continue - raise ValueError(f"Could not extract {timestamp_type} timestamp from comment element") + msg = f"Could not extract {timestamp_type} timestamp from comment element" + raise ValueError(msg) def _is_bot_author(element: Locator, author: str) -> bool: From e4eca8c4cd8ac7558c3517ea9e92358b174bd63e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 25 Nov 2025 15:40:44 -0600 Subject: [PATCH 60/64] fix lint, none checks on username for bot --- src/psrt_ghsa_bot/comment_processor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py index dc4af0b..6638bba 100644 --- a/src/psrt_ghsa_bot/comment_processor.py +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -55,6 +55,10 @@ def process_ghsa_comments( Number of commands executed """ ghsa_key = f"{owner}/{repo}/{ghsa_id}" + bot_username = playwright_client.username + if not bot_username: + msg = "Bot username not configured" + raise RuntimeError(msg) try: comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id) @@ -76,11 +80,11 @@ def process_ghsa_comments( author = comment.author body = comment.body - if author == playwright_client.username: + if author == bot_username: logger.debug("Skipping bot's own comment: %s", comment_id) continue - cmd = parse_command(body, author, comment_id, playwright_client.username, comment.created_at) + cmd = parse_command(body, author, comment_id, bot_username, comment.created_at) if cmd is None: logger.debug("No command in comment from @%s", author) From cd2a22d3c88c297d615064b35c8ef08b6193293d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 26 Nov 2025 09:49:39 -0600 Subject: [PATCH 61/64] move from parsed_data to json because githubkit but with pydnatic --- src/psrt_ghsa_bot/IDEAS.md | 3 ++- src/psrt_ghsa_bot/commands/authorization.py | 22 ++++++++++----------- src/psrt_ghsa_bot/commands/executor.py | 21 +++++++++++--------- state.json | 10 +++++++++- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/psrt_ghsa_bot/IDEAS.md b/src/psrt_ghsa_bot/IDEAS.md index 33226ea..6c7f2c5 100644 --- a/src/psrt_ghsa_bot/IDEAS.md +++ b/src/psrt_ghsa_bot/IDEAS.md @@ -2,4 +2,5 @@ to find all mentions from their notification and act on that instead of scraping? that would mean we wouldnt have to keep state tracking and processing things "AFTER or ON" whenever the gh action ran las based on state -- \ No newline at end of file +- If Playwright action runs before cron action, should we have playwright kick it off + so that the right groups are assigned? diff --git a/src/psrt_ghsa_bot/commands/authorization.py b/src/psrt_ghsa_bot/commands/authorization.py index d1fba4d..fd8f498 100644 --- a/src/psrt_ghsa_bot/commands/authorization.py +++ b/src/psrt_ghsa_bot/commands/authorization.py @@ -118,23 +118,22 @@ def _is_ghsa_collaborator( True if user is a collaborator on the advisory """ try: - advisory = github.rest.security_advisories.get_repository_advisory( + response = github.rest.security_advisories.get_repository_advisory( owner=owner, repo=repo, ghsa_id=ghsa_id, ) + advisory = response.json() - if not advisory.parsed_data: - return False - - collaborators = advisory.parsed_data.collaborating_users or [] - teams = advisory.parsed_data.collaborating_teams or [] + collaborators = advisory.get("collaborating_users") or [] + teams = advisory.get("collaborating_teams") or [] for collaborator in collaborators: - if collaborator.login and collaborator.login.lower() == username.lower(): + login = collaborator.get("login") + if login and login.lower() == username.lower(): return True - return any(team.slug and _is_team_member(github, owner, team.slug, username) for team in teams) + return any((slug := team.get("slug")) and _is_team_member(github, owner, slug, username) for team in teams) except Exception: return False @@ -191,10 +190,11 @@ def _is_repo_admin( repo=repo, username=username, ) - - if not response.parsed_data or not response.parsed_data.permission: + data = response.json() + permission = data.get("permission") + if not permission: return False except Exception: return False else: - return response.parsed_data.permission == "admin" + return permission == "admin" diff --git a/src/psrt_ghsa_bot/commands/executor.py b/src/psrt_ghsa_bot/commands/executor.py index 135df3a..da7abe3 100644 --- a/src/psrt_ghsa_bot/commands/executor.py +++ b/src/psrt_ghsa_bot/commands/executor.py @@ -135,12 +135,13 @@ def _handle_status(_cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id CommandResult with status information """ try: - advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + response = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + advisory = response.json() - state = advisory.parsed_data.state - cve_id = advisory.parsed_data.cve_id or "None assigned" - created_at = advisory.parsed_data.created_at - updated_at = advisory.parsed_data.updated_at + state = advisory["state"] + cve_id = advisory.get("cve_id") or "None assigned" + created_at = advisory["created_at"] + updated_at = advisory["updated_at"] created = datetime.fromisoformat(created_at) days_old = (datetime.now(created.tzinfo) - created).days @@ -186,9 +187,10 @@ def _handle_reject(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: cve_id = cmd.arguments[0] try: - advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + response = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + advisory = response.json() - current_cve = advisory.parsed_data.cve_id + current_cve = advisory.get("cve_id") if current_cve is None: return CommandResult( @@ -245,8 +247,9 @@ def _handle_assign_cve(cmd: Command, github: GitHub, owner: str, repo: str, ghsa CommandResult with assignment confirmation """ try: - advisory = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) - current_cve = advisory.parsed_data.cve_id + response = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + advisory = response.json() + current_cve = advisory.get("cve_id") if current_cve is not None: return CommandResult( diff --git a/state.json b/state.json index 0f924f3..a7b9982 100644 --- a/state.json +++ b/state.json @@ -1,5 +1,5 @@ { - "last_run": "2025-11-25T17:49:57.576176+00:00", + "last_run": "2025-11-26T15:48:25.302350+00:00", "ghsas": { "jolt-org/ghsa-testing/GHSA-j5pm-9w6r-h5rr": { "last_processed_at": "2025-11-25T17:49:46.335109+00:00", @@ -8,6 +8,14 @@ "jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf": { "last_processed_at": "2025-11-25T17:49:57.575833+00:00", "commands_processed_count": 1 + }, + "jolt-org/ghsa-testing/GHSA-r5v8-ggrp-885r": { + "last_processed_at": "2025-11-26T15:39:50.646053+00:00", + "commands_processed_count": 5 + }, + "jolt-org/ghsa-testing/GHSA-v2g9-x2f3-j9g6": { + "last_processed_at": "2025-11-26T15:48:20.365872+00:00", + "commands_processed_count": 4 } }, "commands_to_reprocess": [] From 2b64e6cac6ce648b96daba182ec46d193ce8be0b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 26 Nov 2025 09:49:39 -0600 Subject: [PATCH 62/64] move from parsed_data to json because githubkit but with pydnatic --- src/psrt_ghsa_bot/IDEAS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/psrt_ghsa_bot/IDEAS.md b/src/psrt_ghsa_bot/IDEAS.md index 6c7f2c5..3dd83e7 100644 --- a/src/psrt_ghsa_bot/IDEAS.md +++ b/src/psrt_ghsa_bot/IDEAS.md @@ -4,3 +4,14 @@ "AFTER or ON" whenever the gh action ran las based on state - If Playwright action runs before cron action, should we have playwright kick it off so that the right groups are assigned? + +- When someone duplicate a coammand we shouldnt run it twice: + ``` + 2025-11-19 21:56:42,654 - __main__ - INFO - Command executed successfully: help + 2025-11-19 21:56:42,655 - __main__ - INFO - Executing command: help from @JacobCoffee on GHSA-j5pm-9w6r-h5rr + 2025-11-19 21:56:46,917 - __main__ - INFO - Command executed successfully: help + 2025-11-19 21:56:46,918 - __main__ - INFO - Checking GHSA: jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf (state: draft) + ``` + Caused 2 bot responses that were huge, so we should just do one somehow. +- for running out of headless, record video, etc. set sentinels in settings.py then use make targets +- if someone has multiple @s in the bot maybe we can process all found From f76e513d71a01e8f9a187c3875faf171f99a1225 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 26 Nov 2025 10:21:14 -0600 Subject: [PATCH 63/64] update tests --- tests/conftest.py | 2 +- tests/test_executor.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index df8c510..33f01fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,7 +69,7 @@ def test_ghsa() -> Generator[dict[str, str]]: }, ) - ghsa_id = response.parsed_data.ghsa_id + ghsa_id = response.json()["ghsa_id"] yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} diff --git a/tests/test_executor.py b/tests/test_executor.py index bf89637..408479c 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -69,12 +69,12 @@ def test_execute_status_command_stub(self, mock_github, mock_playwright) -> None mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) advisory_response = Mock() - advisory_response.parsed_data = Mock( - state="draft", - cve_id="CVE-2024-1234", - created_at="2024-01-01T00:00:00Z", - updated_at="2024-01-15T00:00:00Z", - ) + advisory_response.json.return_value = { + "state": "draft", + "cve_id": "CVE-2024-1234", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T00:00:00Z", + } mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response result = execute_command( @@ -104,7 +104,7 @@ def test_execute_reject_command_with_cve(self, mock_github, mock_playwright) -> mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) advisory_response = Mock() - advisory_response.parsed_data = Mock(cve_id="CVE-2024-1234") + advisory_response.json.return_value = {"cve_id": "CVE-2024-1234"} mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response result = execute_command( @@ -157,7 +157,7 @@ def test_execute_assign_cve_command(self, mock_github, mock_playwright) -> None: mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) advisory_response = Mock() - advisory_response.parsed_data = Mock(cve_id=None) + advisory_response.json.return_value = {"cve_id": None} mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response result = execute_command( From 3cdb4d78c3b594048d024595c56ea94a8c3c0df9 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 26 Nov 2025 10:37:56 -0600 Subject: [PATCH 64/64] toggleable commenting --- .env.example | 3 +++ src/psrt_ghsa_bot/comment_processor.py | 25 +++++++++++++++++++++++-- src/psrt_ghsa_bot/config.py | 3 +++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 438a498..b4e1ea8 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ CVE_ENV="testproddev" # Sentry SENTRY_DSN= + +# Set to "true" to disable comment posting (staging/testing mode) +DONT_COMMENT=false diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py index 6638bba..05aaf77 100644 --- a/src/psrt_ghsa_bot/comment_processor.py +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -9,6 +9,7 @@ from dotenv import load_dotenv from githubkit import AppAuthStrategy, GitHub +from psrt_ghsa_bot import config from psrt_ghsa_bot.app import get_repository_advisories from psrt_ghsa_bot.commands.executor import execute_command from psrt_ghsa_bot.commands.parser import parse_command @@ -21,6 +22,23 @@ logger = logging.getLogger(__name__) +def _maybe_post_comment( + playwright_client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, + message: str, +) -> None: + """Post a comment if DONT_COMMENT is not set, otherwise log what would be posted. + + Fixes PLR0912 Too many branches + """ + if config.DONT_COMMENT: + logger.info("[DONT_COMMENT] Would post: %s...", message[:100]) + else: + post_ghsa_comment(playwright_client, owner, repo, ghsa_id, message) + + @dataclass class CommentProcessingStats: """Statistics for a comment processing run.""" @@ -97,7 +115,7 @@ def process_ghsa_comments( logger.info("Executing command: %s from @%s on %s", cmd.action, author, ghsa_id) try: result = execute_command(cmd, github, playwright_client, owner, repo, ghsa_id) - post_ghsa_comment(playwright_client, owner, repo, ghsa_id, result.message) + _maybe_post_comment(playwright_client, owner, repo, ghsa_id, result.message) state_manager.mark_command_processed(ghsa_key, comment_id) commands_executed += 1 logger.info("Command executed successfully: %s", cmd.action) @@ -110,7 +128,7 @@ def process_ghsa_comments( ) with contextlib.suppress(Exception): - post_ghsa_comment(playwright_client, owner, repo, ghsa_id, error_message) + _maybe_post_comment(playwright_client, owner, repo, ghsa_id, error_message) return commands_executed @@ -191,6 +209,9 @@ def main() -> None: logger.info("PSRT GHSA Bot - Comment Processor") logger.info("=" * 50) + if config.DONT_COMMENT: + logger.warning("DONT_COMMENT MODE ENABLED - Comments will NOT be posted") + logger.info("Initializing GitHub API client...") gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() github = GitHub(AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key)) diff --git a/src/psrt_ghsa_bot/config.py b/src/psrt_ghsa_bot/config.py index 2c22b2b..5ef9517 100644 --- a/src/psrt_ghsa_bot/config.py +++ b/src/psrt_ghsa_bot/config.py @@ -1,7 +1,10 @@ """Common configuration for the PSRT GHSA Bot.""" +import os from typing import Final, Literal +DONT_COMMENT: bool = os.getenv("DONT_COMMENT", "").lower() in ("true", "1", "yes") + type CheckinStatus = Literal["in_progress", "ok", "error"] MONITOR_SLUG_HEALTH: Final[str] = "psrt-health-monitor"