From 5d0a7ccfdf34b35c978c8bc5e31b032aaebe3406 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 14:01:20 -0600 Subject: [PATCH 01/29] use uv and githubkit --- .env.example | 5 + .python-version | 1 + Makefile | 29 +++ app.py | 174 ++++++------- pyproject.toml | 35 +++ requirements.in | 4 - requirements.txt | 424 ------------------------------ test_app.py | 140 +++++----- uv.lock | 666 +++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 894 insertions(+), 584 deletions(-) create mode 100644 .env.example create mode 100644 .python-version create mode 100644 Makefile create mode 100644 pyproject.toml delete mode 100644 requirements.in delete mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0ff71c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +GH_CLIENT_ID="123456" +GH_CLIENT_PRIVATE_KEY="base64...your...pem...keyfile" +CVE_USERNAME="user@example.org" +CVE_API_KEY="123456" +CVE_ENV="testproddev" diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..12566ed --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14.0 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c0e2800 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.DEFAULT_GOAL:=help +.ONESHELL: + +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) + +upgrade: ## Upgrade all dependencies to the latest stable versions + @uv lock --upgrade + @echo "=> Dependencies Updated" + +lint: ## Lint the code + @uv run ruff check --fix --unsafe-fixes . + +fmt: ## Format the code + @uv run ruff format . + +mt-check: ## Runs Ruff format in check mode (no changes) + @uv run --no-sync ruff format --check . + +type-check: ## Run type-checking + @uv run ty check + +test: ## Run tests + @uv run pytest test_app.py + +ci: lint fmt type-check test ## Run everything + +app: ## Run the app + @uv run python app.py \ No newline at end of file diff --git a/app.py b/app.py index d66d89e..2fa08ce 100644 --- a/app.py +++ b/app.py @@ -1,78 +1,47 @@ -"""GitHub application which applies the PSRT process for GitHub Security Advisories""" +"""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 github.Auth import AppAuth -from github.GithubIntegration import GithubIntegration -from github.GithubObject import NotSet, Opt -from github.PaginatedList import PaginatedList -from github.Repository import Repository -from github.RepositoryAdvisory import RepositoryAdvisory - - -PSRT_GITHUB_TEAM_SLUG = "psrt" - - -class RepositoryAdvisoryWithTeams(RepositoryAdvisory): - """Patch for PyGithub to support the 'collaborating_teams' field""" +from dotenv import load_dotenv +from githubkit import AppAuthStrategy, GitHub - @property - def collaborating_teams(self) -> list[str]: - return self._collaborating_teams +# Load environment variables from .env file +load_dotenv() - def edit( - self, - cve_id: Opt[str] = NotSet, - collaborating_teams: Opt[list[str]] = NotSet, - ) -> "RepositoryAdvisoryWithTeams": - """This is adapted from PyGithub's implementation, but only for the properties we need.""" +if typing.TYPE_CHECKING: + pass - assert cve_id is NotSet or isinstance(cve_id, str), cve_id - assert collaborating_teams is NotSet or ( - isinstance(collaborating_teams, typing.Iterable), - collaborating_teams - and all(isinstance(element, str) for element in collaborating_teams), - ) - patch_parameters: dict[str, typing.Any] = {} - if cve_id is not NotSet: - patch_parameters["cve_id"] = cve_id - if collaborating_teams is not NotSet: - patch_parameters["collaborating_teams"] = collaborating_teams - - headers, data = self._requester.requestJsonAndCheck( - "PATCH", - self.url, - input=patch_parameters, - ) - self._useAttributes(data) - return self - - def _initAttributes(self) -> None: - self._collaborating_teams = NotSet - super()._initAttributes() - - def _useAttributes(self, attributes: dict[str, typing.Any]) -> None: - if "collaborating_teams" in attributes: - # We only need the slugs, makes it easier to compare later. - collaborating_teams = [team["slug"] for team in attributes["collaborating_teams"]] - self._collaborating_teams = collaborating_teams - super()._useAttributes(attributes) +PSRT_GITHUB_TEAM_SLUG = "psrt" def get_repository_advisories( - repo: Repository, -) -> typing.Iterable["RepositoryAdvisoryWithTeams"]: - """Mimics get_repository_advisories(), except injects our own class.""" - - return PaginatedList( - RepositoryAdvisoryWithTeams, - repo._requester, - f"{repo.url}/security-advisories", - None, - ) + 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: @@ -83,43 +52,56 @@ def reserve_one_cve(cve_api: CveApi) -> str: return cve_ids[0] -def apply_to_repo(repo: Repository, cve_api: CveApi) -> None: +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(repo) + 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 security_advisory.state not in ("triage", "draft"): + if state not in ("triage", "draft"): + print(f" â­ī¸ Skipping {ghsa_id} (state: {state})") continue - # Maintain a list of updates to make and then submit them all at once. - edit_kwargs = {} + 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 security_advisory.state == "draft" and security_advisory.cve_id is None: + if state == "draft" and security_advisory.get("cve_id") is None: cve_id = reserve_one_cve(cve_api) - edit_kwargs["cve_id"] = cve_id - - # If the PSRT GitHub team hasn't been added to the repository - # we append it to the advisory. - if PSRT_GITHUB_TEAM_SLUG not in security_advisory.collaborating_teams: - # Maintain all existing teams during the update. - edit_kwargs["collaborating_teams"] = ( - [PSRT_GITHUB_TEAM_SLUG] - + list(security_advisory.collaborating_teams) - ) + 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 edit_kwargs: - security_advisory.edit(**edit_kwargs) + 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_app = GithubIntegration( - auth=AppAuth(os.environ["GH_CLIENT_ID"], gh_client_private_key) + github = GitHub( + AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key), ) cve_api = CveApi( org="PSF", @@ -128,12 +110,28 @@ def main() -> None: env=os.environ.get("CVE_ENV", "prod"), ) + print("Fetching installations...") # Apply to all repositories for each installation. - installations = github_app.get_installations() - for installation in installations: - repos = installation.get_repos() + 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: - apply_to_repo(repo, cve_api) + 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__": diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4fe5c97 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "psrt-ghsa-bot" +version = "0.1.0" +description = "GitHub bot for PSRT activities on GHSA" +readme = "README.md" +requires-python = ">=3.14.0" +dependencies = [ + "cvelib>=1.4.0", + "githubkit[auth-app]>=0.13.5", + "python-dotenv>=1.0.0", +] + +[dependency-groups] +dev = [ + "mock>=5.2.0", + "pytest>=8.4.2", + "ruff>=0.14.3", + "ty>=0.0.1a25", +] + +[tool.ruff] +target-version = "py314" +src = ["app", "tests"] +line-length = 120 +indent-width = 4 + +[tool.ruff.lint] +ignore = ["D203", "D213", "COM812"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 9e5fcd9..0000000 --- a/requirements.in +++ /dev/null @@ -1,4 +0,0 @@ ---only-binary=:all: - -pygithub -cvelib diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f0942d4..0000000 --- a/requirements.txt +++ /dev/null @@ -1,424 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --generate-hashes --output-file=requirements.txt requirements.in -# ---only-binary :all: - -attrs==23.2.0 \ - --hash=sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1 - # via - # jsonschema - # referencing -certifi==2024.6.2 \ - --hash=sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56 - # via requests -cffi==1.16.0 \ - --hash=sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc \ - --hash=sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a \ - --hash=sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417 \ - --hash=sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab \ - --hash=sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520 \ - --hash=sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36 \ - --hash=sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743 \ - --hash=sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8 \ - --hash=sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed \ - --hash=sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684 \ - --hash=sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56 \ - --hash=sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324 \ - --hash=sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d \ - --hash=sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235 \ - --hash=sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e \ - --hash=sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088 \ - --hash=sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000 \ - --hash=sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7 \ - --hash=sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e \ - --hash=sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673 \ - --hash=sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c \ - --hash=sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe \ - --hash=sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2 \ - --hash=sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098 \ - --hash=sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8 \ - --hash=sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a \ - --hash=sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0 \ - --hash=sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b \ - --hash=sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896 \ - --hash=sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e \ - --hash=sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9 \ - --hash=sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2 \ - --hash=sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b \ - --hash=sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6 \ - --hash=sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404 \ - --hash=sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f \ - --hash=sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4 \ - --hash=sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc \ - --hash=sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936 \ - --hash=sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba \ - --hash=sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872 \ - --hash=sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb \ - --hash=sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614 \ - --hash=sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1 \ - --hash=sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d \ - --hash=sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969 \ - --hash=sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b \ - --hash=sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4 \ - --hash=sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627 \ - --hash=sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956 \ - --hash=sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357 - # via - # cryptography - # pynacl -charset-normalizer==3.3.2 \ - --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ - --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ - --hash=sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786 \ - --hash=sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8 \ - --hash=sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09 \ - --hash=sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185 \ - --hash=sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574 \ - --hash=sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e \ - --hash=sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519 \ - --hash=sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898 \ - --hash=sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269 \ - --hash=sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3 \ - --hash=sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f \ - --hash=sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6 \ - --hash=sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8 \ - --hash=sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a \ - --hash=sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73 \ - --hash=sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc \ - --hash=sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714 \ - --hash=sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2 \ - --hash=sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc \ - --hash=sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce \ - --hash=sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d \ - --hash=sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e \ - --hash=sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6 \ - --hash=sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269 \ - --hash=sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96 \ - --hash=sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d \ - --hash=sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a \ - --hash=sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4 \ - --hash=sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77 \ - --hash=sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d \ - --hash=sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0 \ - --hash=sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed \ - --hash=sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068 \ - --hash=sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac \ - --hash=sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25 \ - --hash=sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8 \ - --hash=sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab \ - --hash=sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26 \ - --hash=sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2 \ - --hash=sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db \ - --hash=sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f \ - --hash=sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5 \ - --hash=sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99 \ - --hash=sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c \ - --hash=sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d \ - --hash=sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811 \ - --hash=sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa \ - --hash=sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a \ - --hash=sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03 \ - --hash=sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b \ - --hash=sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04 \ - --hash=sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c \ - --hash=sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001 \ - --hash=sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458 \ - --hash=sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389 \ - --hash=sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99 \ - --hash=sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985 \ - --hash=sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537 \ - --hash=sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238 \ - --hash=sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f \ - --hash=sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d \ - --hash=sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796 \ - --hash=sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a \ - --hash=sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143 \ - --hash=sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8 \ - --hash=sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c \ - --hash=sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5 \ - --hash=sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5 \ - --hash=sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711 \ - --hash=sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4 \ - --hash=sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6 \ - --hash=sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c \ - --hash=sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7 \ - --hash=sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4 \ - --hash=sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b \ - --hash=sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae \ - --hash=sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12 \ - --hash=sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c \ - --hash=sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae \ - --hash=sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8 \ - --hash=sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887 \ - --hash=sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b \ - --hash=sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4 \ - --hash=sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f \ - --hash=sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33 \ - --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \ - --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561 - # via requests -click==8.1.7 \ - --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 - # via cvelib -cryptography==42.0.8 \ - --hash=sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad \ - --hash=sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583 \ - --hash=sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b \ - --hash=sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c \ - --hash=sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1 \ - --hash=sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648 \ - --hash=sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949 \ - --hash=sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba \ - --hash=sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c \ - --hash=sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9 \ - --hash=sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d \ - --hash=sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c \ - --hash=sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e \ - --hash=sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d \ - --hash=sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7 \ - --hash=sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70 \ - --hash=sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2 \ - --hash=sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7 \ - --hash=sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14 \ - --hash=sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe \ - --hash=sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e \ - --hash=sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71 \ - --hash=sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961 \ - --hash=sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7 \ - --hash=sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c \ - --hash=sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28 \ - --hash=sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842 \ - --hash=sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902 \ - --hash=sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801 \ - --hash=sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a \ - --hash=sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e - # via pyjwt -cvelib==1.4.0 \ - --hash=sha256:9a32cdcf1f9273c1d5d90e1d396b7f4a39ea4a44835123c8946e1e7f49cbcdcf - # via -r requirements.in -deprecated==1.2.14 \ - --hash=sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c - # via pygithub -idna==3.7 \ - --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 - # via requests -jsonschema==4.22.0 \ - --hash=sha256:ff4cfd6b1367a40e7bc6411caec72effadd3db0bbe5017de188f2d6108335802 - # via cvelib -jsonschema-specifications==2023.12.1 \ - --hash=sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c - # via jsonschema -pycparser==2.22 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc - # via cffi -pygithub==2.3.0 \ - --hash=sha256:65b499728be3ce7b0cd2cd760da3b32f0f4d7bc55e5e0677617f90f6564e793e - # via -r requirements.in -pyjwt[crypto]==2.8.0 \ - --hash=sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320 - # via pygithub -pynacl==1.5.0 \ - --hash=sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858 \ - --hash=sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d \ - --hash=sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93 \ - --hash=sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1 \ - --hash=sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92 \ - --hash=sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff \ - --hash=sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394 \ - --hash=sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b \ - --hash=sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543 - # via pygithub -referencing==0.35.1 \ - --hash=sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de - # via - # jsonschema - # jsonschema-specifications -requests==2.32.3 \ - --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 - # via - # cvelib - # pygithub -rpds-py==0.18.1 \ - --hash=sha256:05f3d615099bd9b13ecf2fc9cf2d839ad3f20239c678f461c753e93755d629ee \ - --hash=sha256:06d218939e1bf2ca50e6b0ec700ffe755e5216a8230ab3e87c059ebb4ea06afc \ - --hash=sha256:07f2139741e5deb2c5154a7b9629bc5aa48c766b643c1a6750d16f865a82c5fc \ - --hash=sha256:08d74b184f9ab6289b87b19fe6a6d1a97fbfea84b8a3e745e87a5de3029bf944 \ - --hash=sha256:0abeee75434e2ee2d142d650d1e54ac1f8b01e6e6abdde8ffd6eeac6e9c38e20 \ - --hash=sha256:154bf5c93d79558b44e5b50cc354aa0459e518e83677791e6adb0b039b7aa6a7 \ - --hash=sha256:17c6d2155e2423f7e79e3bb18151c686d40db42d8645e7977442170c360194d4 \ - --hash=sha256:1805d5901779662d599d0e2e4159d8a82c0b05faa86ef9222bf974572286b2b6 \ - --hash=sha256:19ba472b9606c36716062c023afa2484d1e4220548751bda14f725a7de17b4f6 \ - --hash=sha256:19e515b78c3fc1039dd7da0a33c28c3154458f947f4dc198d3c72db2b6b5dc93 \ - --hash=sha256:1d54f74f40b1f7aaa595a02ff42ef38ca654b1469bef7d52867da474243cc633 \ - --hash=sha256:207c82978115baa1fd8d706d720b4a4d2b0913df1c78c85ba73fe6c5804505f0 \ - --hash=sha256:2625f03b105328729f9450c8badda34d5243231eef6535f80064d57035738360 \ - --hash=sha256:27bba383e8c5231cd559affe169ca0b96ec78d39909ffd817f28b166d7ddd4d8 \ - --hash=sha256:2c3caec4ec5cd1d18e5dd6ae5194d24ed12785212a90b37f5f7f06b8bedd7139 \ - --hash=sha256:2cc7c1a47f3a63282ab0f422d90ddac4aa3034e39fc66a559ab93041e6505da7 \ - --hash=sha256:2fc24a329a717f9e2448f8cd1f960f9dac4e45b6224d60734edeb67499bab03a \ - --hash=sha256:312fe69b4fe1ffbe76520a7676b1e5ac06ddf7826d764cc10265c3b53f96dbe9 \ - --hash=sha256:32b7daaa3e9389db3695964ce8e566e3413b0c43e3394c05e4b243a4cd7bef26 \ - --hash=sha256:338dee44b0cef8b70fd2ef54b4e09bb1b97fc6c3a58fea5db6cc083fd9fc2724 \ - --hash=sha256:352a88dc7892f1da66b6027af06a2e7e5d53fe05924cc2cfc56495b586a10b72 \ - --hash=sha256:35b2b771b13eee8729a5049c976197ff58a27a3829c018a04341bcf1ae409b2b \ - --hash=sha256:38e14fb4e370885c4ecd734f093a2225ee52dc384b86fa55fe3f74638b2cfb09 \ - --hash=sha256:3c20f05e8e3d4fc76875fc9cb8cf24b90a63f5a1b4c5b9273f0e8225e169b100 \ - --hash=sha256:3dd3cd86e1db5aadd334e011eba4e29d37a104b403e8ca24dcd6703c68ca55b3 \ - --hash=sha256:489bdfe1abd0406eba6b3bb4fdc87c7fa40f1031de073d0cfb744634cc8fa261 \ - --hash=sha256:48c2faaa8adfacefcbfdb5f2e2e7bdad081e5ace8d182e5f4ade971f128e6bb3 \ - --hash=sha256:4a98a1f0552b5f227a3d6422dbd61bc6f30db170939bd87ed14f3c339aa6c7c9 \ - --hash=sha256:4adec039b8e2928983f885c53b7cc4cda8965b62b6596501a0308d2703f8af1b \ - --hash=sha256:4e0ee01ad8260184db21468a6e1c37afa0529acc12c3a697ee498d3c2c4dcaf3 \ - --hash=sha256:51584acc5916212e1bf45edd17f3a6b05fe0cbb40482d25e619f824dccb679de \ - --hash=sha256:531796fb842b53f2695e94dc338929e9f9dbf473b64710c28af5a160b2a8927d \ - --hash=sha256:5463c47c08630007dc0fe99fb480ea4f34a89712410592380425a9b4e1611d8e \ - --hash=sha256:5c45a639e93a0c5d4b788b2613bd637468edd62f8f95ebc6fcc303d58ab3f0a8 \ - --hash=sha256:6031b25fb1b06327b43d841f33842b383beba399884f8228a6bb3df3088485ff \ - --hash=sha256:607345bd5912aacc0c5a63d45a1f73fef29e697884f7e861094e443187c02be5 \ - --hash=sha256:618916f5535784960f3ecf8111581f4ad31d347c3de66d02e728de460a46303c \ - --hash=sha256:636a15acc588f70fda1661234761f9ed9ad79ebed3f2125d44be0862708b666e \ - --hash=sha256:673fdbbf668dd958eff750e500495ef3f611e2ecc209464f661bc82e9838991e \ - --hash=sha256:6afd80f6c79893cfc0574956f78a0add8c76e3696f2d6a15bca2c66c415cf2d4 \ - --hash=sha256:6b5ff7e1d63a8281654b5e2896d7f08799378e594f09cf3674e832ecaf396ce8 \ - --hash=sha256:6c4c4c3f878df21faf5fac86eda32671c27889e13570645a9eea0a1abdd50922 \ - --hash=sha256:6cd8098517c64a85e790657e7b1e509b9fe07487fd358e19431cb120f7d96338 \ - --hash=sha256:6d1e42d2735d437e7e80bab4d78eb2e459af48c0a46e686ea35f690b93db792d \ - --hash=sha256:6e30ac5e329098903262dc5bdd7e2086e0256aa762cc8b744f9e7bf2a427d3f8 \ - --hash=sha256:70a838f7754483bcdc830444952fd89645569e7452e3226de4a613a4c1793fb2 \ - --hash=sha256:720edcb916df872d80f80a1cc5ea9058300b97721efda8651efcd938a9c70a72 \ - --hash=sha256:732672fbc449bab754e0b15356c077cc31566df874964d4801ab14f71951ea80 \ - --hash=sha256:740884bc62a5e2bbb31e584f5d23b32320fd75d79f916f15a788d527a5e83644 \ - --hash=sha256:7700936ef9d006b7ef605dc53aa364da2de5a3aa65516a1f3ce73bf82ecfc7ae \ - --hash=sha256:7732770412bab81c5a9f6d20aeb60ae943a9b36dcd990d876a773526468e7163 \ - --hash=sha256:7750569d9526199c5b97e5a9f8d96a13300950d910cf04a861d96f4273d5b104 \ - --hash=sha256:7f1944ce16401aad1e3f7d312247b3d5de7981f634dc9dfe90da72b87d37887d \ - --hash=sha256:81c5196a790032e0fc2464c0b4ab95f8610f96f1f2fa3d4deacce6a79852da60 \ - --hash=sha256:8352f48d511de5f973e4f2f9412736d7dea76c69faa6d36bcf885b50c758ab9a \ - --hash=sha256:8927638a4d4137a289e41d0fd631551e89fa346d6dbcfc31ad627557d03ceb6d \ - --hash=sha256:8c7672e9fba7425f79019db9945b16e308ed8bc89348c23d955c8c0540da0a07 \ - --hash=sha256:8d2e182c9ee01135e11e9676e9a62dfad791a7a467738f06726872374a83db49 \ - --hash=sha256:910e71711d1055b2768181efa0a17537b2622afeb0424116619817007f8a2b10 \ - --hash=sha256:942695a206a58d2575033ff1e42b12b2aece98d6003c6bc739fbf33d1773b12f \ - --hash=sha256:9437ca26784120a279f3137ee080b0e717012c42921eb07861b412340f85bae2 \ - --hash=sha256:967342e045564cef76dfcf1edb700b1e20838d83b1aa02ab313e6a497cf923b8 \ - --hash=sha256:998125738de0158f088aef3cb264a34251908dd2e5d9966774fdab7402edfab7 \ - --hash=sha256:9e6934d70dc50f9f8ea47081ceafdec09245fd9f6032669c3b45705dea096b88 \ - --hash=sha256:a3d456ff2a6a4d2adcdf3c1c960a36f4fd2fec6e3b4902a42a384d17cf4e7a65 \ - --hash=sha256:a7b28c5b066bca9a4eb4e2f2663012debe680f097979d880657f00e1c30875a0 \ - --hash=sha256:a888e8bdb45916234b99da2d859566f1e8a1d2275a801bb8e4a9644e3c7e7909 \ - --hash=sha256:aa3679e751408d75a0b4d8d26d6647b6d9326f5e35c00a7ccd82b78ef64f65f8 \ - --hash=sha256:aaa71ee43a703c321906813bb252f69524f02aa05bf4eec85f0c41d5d62d0f4c \ - --hash=sha256:b646bf655b135ccf4522ed43d6902af37d3f5dbcf0da66c769a2b3938b9d8184 \ - --hash=sha256:b906b5f58892813e5ba5c6056d6a5ad08f358ba49f046d910ad992196ea61397 \ - --hash=sha256:b9bb1f182a97880f6078283b3505a707057c42bf55d8fca604f70dedfdc0772a \ - --hash=sha256:bd1105b50ede37461c1d51b9698c4f4be6e13e69a908ab7751e3807985fc0346 \ - --hash=sha256:bf18932d0003c8c4d51a39f244231986ab23ee057d235a12b2684ea26a353590 \ - --hash=sha256:c273e795e7a0f1fddd46e1e3cb8be15634c29ae8ff31c196debb620e1edb9333 \ - --hash=sha256:c69882964516dc143083d3795cb508e806b09fc3800fd0d4cddc1df6c36e76bb \ - --hash=sha256:c827576e2fa017a081346dce87d532a5310241648eb3700af9a571a6e9fc7e74 \ - --hash=sha256:cbfbea39ba64f5e53ae2915de36f130588bba71245b418060ec3330ebf85678e \ - --hash=sha256:ce0bb20e3a11bd04461324a6a798af34d503f8d6f1aa3d2aa8901ceaf039176d \ - --hash=sha256:d0cee71bc618cd93716f3c1bf56653740d2d13ddbd47673efa8bf41435a60daa \ - --hash=sha256:d21be4770ff4e08698e1e8e0bce06edb6ea0626e7c8f560bc08222880aca6a6f \ - --hash=sha256:d31dea506d718693b6b2cffc0648a8929bdc51c70a311b2770f09611caa10d53 \ - --hash=sha256:d44607f98caa2961bab4fa3c4309724b185b464cdc3ba6f3d7340bac3ec97cc1 \ - --hash=sha256:d58ad6317d188c43750cb76e9deacf6051d0f884d87dc6518e0280438648a9ac \ - --hash=sha256:d70129cef4a8d979caa37e7fe957202e7eee8ea02c5e16455bc9808a59c6b2f0 \ - --hash=sha256:d85164315bd68c0806768dc6bb0429c6f95c354f87485ee3593c4f6b14def2bd \ - --hash=sha256:d960de62227635d2e61068f42a6cb6aae91a7fe00fca0e3aeed17667c8a34611 \ - --hash=sha256:e1735502458621921cee039c47318cb90b51d532c2766593be6207eec53e5c4c \ - --hash=sha256:e2be6e9dd4111d5b31ba3b74d17da54a8319d8168890fbaea4b9e5c3de630ae5 \ - --hash=sha256:e4c39ad2f512b4041343ea3c7894339e4ca7839ac38ca83d68a832fc8b3748ab \ - --hash=sha256:ed402d6153c5d519a0faf1bb69898e97fb31613b49da27a84a13935ea9164dfc \ - --hash=sha256:ee17cd26b97d537af8f33635ef38be873073d516fd425e80559f4585a7b90c43 \ - --hash=sha256:f3027be483868c99b4985fda802a57a67fdf30c5d9a50338d9db646d590198da \ - --hash=sha256:f5bab211605d91db0e2995a17b5c6ee5edec1270e46223e513eaa20da20076ac \ - --hash=sha256:f6f8e3fecca256fefc91bb6765a693d96692459d7d4c644660a9fff32e517843 \ - --hash=sha256:f7afbfee1157e0f9376c00bb232e80a60e59ed716e3211a80cb8506550671e6e \ - --hash=sha256:fa242ac1ff583e4ec7771141606aafc92b361cd90a05c30d93e343a0c2d82a89 \ - --hash=sha256:fab6ce90574645a0d6c58890e9bcaac8d94dff54fb51c69e5522a7358b80ab64 - # via - # jsonschema - # referencing -typing-extensions==4.12.2 \ - --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d - # via pygithub -urllib3==2.2.2 \ - --hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472 - # via - # pygithub - # requests -wrapt==1.16.0 \ - --hash=sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc \ - --hash=sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81 \ - --hash=sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09 \ - --hash=sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e \ - --hash=sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca \ - --hash=sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0 \ - --hash=sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb \ - --hash=sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487 \ - --hash=sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40 \ - --hash=sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c \ - --hash=sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060 \ - --hash=sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202 \ - --hash=sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41 \ - --hash=sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9 \ - --hash=sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b \ - --hash=sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664 \ - --hash=sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362 \ - --hash=sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00 \ - --hash=sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc \ - --hash=sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1 \ - --hash=sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267 \ - --hash=sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956 \ - --hash=sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966 \ - --hash=sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1 \ - --hash=sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228 \ - --hash=sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72 \ - --hash=sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d \ - --hash=sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292 \ - --hash=sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0 \ - --hash=sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0 \ - --hash=sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36 \ - --hash=sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c \ - --hash=sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5 \ - --hash=sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f \ - --hash=sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73 \ - --hash=sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b \ - --hash=sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2 \ - --hash=sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593 \ - --hash=sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39 \ - --hash=sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389 \ - --hash=sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf \ - --hash=sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf \ - --hash=sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89 \ - --hash=sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c \ - --hash=sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c \ - --hash=sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f \ - --hash=sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440 \ - --hash=sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465 \ - --hash=sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136 \ - --hash=sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b \ - --hash=sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8 \ - --hash=sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3 \ - --hash=sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8 \ - --hash=sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6 \ - --hash=sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e \ - --hash=sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f \ - --hash=sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c \ - --hash=sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e \ - --hash=sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8 \ - --hash=sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2 \ - --hash=sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020 \ - --hash=sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35 \ - --hash=sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d \ - --hash=sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3 \ - --hash=sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537 \ - --hash=sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809 \ - --hash=sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d \ - --hash=sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a \ - --hash=sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4 - # via deprecated diff --git a/test_app.py b/test_app.py index e435c67..116bfcc 100644 --- a/test_app.py +++ b/test_app.py @@ -1,18 +1,17 @@ import datetime - -import mock -import pytest +from unittest import mock import app +import pytest -@pytest.fixture() +@pytest.fixture def year() -> str: return str(datetime.date.today().year) @pytest.fixture -def cve_id(year): +def cve_id(year) -> str: return f"CVE-{year}-0000" @@ -29,126 +28,131 @@ def cve_reserve_response(cve_id, year): "state": "RESERVED", "requested_by": {"cna": "PSF", "user": "cna@python.org"}, "requested": "2024-01-01T00:00:00Z", - } + }, ], } -@pytest.mark.parametrize("state", ["draft", "triage"]) -def test_adds_psrt_github_team_to_security_advisories(state): - security_advisory = mock.Mock() - security_advisory.state = state - security_advisory.cve_id = "CVE-0000-0000" - security_advisory.collaborating_teams = [] - - repo = 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(repo, cve_api) - - security_advisory.edit.assert_called_once_with(collaborating_teams=["python/psrt"]) +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_appends_psrt_github_team_to_security_advisories(state): - security_advisory = mock.Mock() - security_advisory.state = state - security_advisory.cve_id = "CVE-0000-0000" - security_advisory.collaborating_teams = ["python/other-team"] +def test_adds_psrt_github_team_to_security_advisories(state) -> None: + security_advisory = _create_advisory_dict(state, "CVE-0000-0000", []) - repo = mock.Mock() + 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(repo, cve_api) + app.apply_to_repo(github, "owner", "repo", cve_api) - security_advisory.edit.assert_called_once_with( - collaborating_teams=["python/psrt", "python/other-team"] + 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_adds_psrt_github_team_to_security_advisories(state): - security_advisory = mock.Mock() - security_advisory.state = state - security_advisory.cve_id = "CVE-0000-0000" - security_advisory.collaborating_teams = [] +def test_appends_psrt_github_team_to_security_advisories(state) -> None: + security_advisory = _create_advisory_dict( + state, + "CVE-0000-0000", + ["python/other-team"], + ) - repo = mock.Mock() + 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(repo, cve_api) + app.apply_to_repo(github, "owner", "repo", cve_api) - security_advisory.edit.assert_called_once_with(collaborating_teams=["python/psrt"]) + 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): - security_advisory = mock.Mock() - security_advisory.state = state - security_advisory.cve_id = None - security_advisory.collaborating_teams = [] +def test_does_not_modify_completed_security_advisories(state) -> None: + security_advisory = _create_advisory_dict(state, None, []) - repo = mock.Mock() + 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(repo, cve_api) + app.apply_to_repo(github, "owner", "repo", cve_api) - security_advisory.edit.assert_not_called() + 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 -): - security_advisory = mock.Mock() - security_advisory.state = "draft" - security_advisory.cve_id = None - security_advisory.collaborating_teams = ["python/psrt"] - - repo = mock.Mock() + 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(repo, cve_api) + app.apply_to_repo(github, "owner", "repo", cve_api) cve_api.reserve.assert_called_with(count=1, year=year, random=True) - security_advisory.edit.assert_called_once_with(cve_id=cve_id) + 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): - security_advisory = mock.Mock() - security_advisory.state = state - security_advisory.cve_id = None - security_advisory.collaborating_teams = ["python/psrt"] +def test_does_not_reserve_cve_id_for_triage_security_advisories(state) -> None: + security_advisory = _create_advisory_dict(state, None, ["psrt"]) - repo = mock.Mock() + 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(repo, cve_api) + app.apply_to_repo(github, "owner", "repo", cve_api) cve_api.reserve.assert_not_called() - security_advisory.edit.assert_not_called() - - -def test_reserve_one_cve_id(cve_reserve_response, cve_id, year): + # 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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3b0ce8e --- /dev/null +++ b/uv.lock @@ -0,0 +1,666 @@ +version = 1 +revision = 3 +requires-python = ">=3.14.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "anysqlite" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +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" } +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" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +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" } +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" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "cvelib" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jsonschema" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/fc/b103c8d1055d7c6f53052003cc581d4ec0462be9517dfed7b818d71251b9/cvelib-1.8.0.tar.gz", hash = "sha256:371984737f29b8e5e433febd07a52eb7d73b4ea9628b79f7b6ce10468feccb3d", size = 82250, upload-time = "2025-10-01T11:54:49.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/28/251ddb7b568ada5ff0cf9def244e4704b8ae61498eaa11c3f602cf9308da/cvelib-1.8.0-py3-none-any.whl", hash = "sha256:9bca8d138e67ec6d6ac50c63040f0262099be2bb6db1c26a05fdd8eee30a939c", size = 75212, upload-time = "2025-10-01T11:54:47.827Z" }, +] + +[[package]] +name = "githubkit" +version = "0.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "hishel" }, + { name = "httpx" }, + { 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" } +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" }, +] + +[package.optional-dependencies] +auth-app = [ + { name = "pyjwt", extra = ["crypto"] }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hishel" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "anysqlite" }, + { name = "httpx" }, + { name = "msgpack" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468, upload-time = "2025-10-18T13:32:41.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/83/4f8b77839e62114bb034375ee8e08cfb6af1164754b925b271d3f1ec06ee/hishel-0.1.5-py3-none-any.whl", hash = "sha256:0bfbe9a2b9342090eba82ba6de88258092e1c4c7b730cd4cb4b570e4b40e44a7", size = 92486, upload-time = "2025-10-18T13:32:40.333Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psrt-ghsa-bot" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "cvelib" }, + { name = "githubkit", extra = ["auth-app"] }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mock" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "cvelib", specifier = ">=1.4.0" }, + { name = "githubkit", extras = ["auth-app"], specifier = ">=0.13.5" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mock", specifier = ">=5.2.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "ruff", specifier = ">=0.14.3" }, + { name = "ty", specifier = ">=0.0.1a25" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { 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" } +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" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +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" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +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 = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[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" }, +] + +[[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" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +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 = "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" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] From f1d21a8114b50c053861910680773f3e49e805d8 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:09:56 -0600 Subject: [PATCH 02/29] missed file removal --- dev-requirements.txt | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 dev-requirements.txt diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 625bed6..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest -mock From 096f40a0849ee09e81db274ac6fdd1276a6ec4e3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:10:14 -0600 Subject: [PATCH 03/29] add playwright basic --- .env.example | 1 + .gitignore | 5 + Makefile | 2 +- pyproject.toml | 9 +- src/psrt_ghsa_bot/__init__.py | 3 + app.py => src/psrt_ghsa_bot/app.py | 1 - .../github_polyfills/__init__.py | 9 ++ test_app.py => tests/test_app.py | 13 +- tests/test_playwright_base.py | 104 ++++++++++++++ uv.lock | 127 +++++++++++++++++- 10 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 src/psrt_ghsa_bot/__init__.py rename app.py => src/psrt_ghsa_bot/app.py (99%) create mode 100644 src/psrt_ghsa_bot/github_polyfills/__init__.py rename test_app.py => tests/test_app.py (91%) 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/.gitignore b/.gitignore index 82f9275..9120a68 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,8 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Playwright authentication state +playwright/.auth/ +playwright-state/ +**/playwright-state/ diff --git a/Makefile b/Makefile index c0e2800..e4daae3 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ type-check: ## Run type-checking @uv run ty check test: ## Run tests - @uv run pytest test_app.py + @uv run pytest ci: lint fmt type-check test ## Run everything diff --git a/pyproject.toml b/pyproject.toml index 4fe5c97..7caa95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.14.0" dependencies = [ "cvelib>=1.4.0", "githubkit[auth-app]>=0.13.5", + "playwright>=1.55.0", "python-dotenv>=1.0.0", ] @@ -14,13 +15,19 @@ dependencies = [ dev = [ "mock>=5.2.0", "pytest>=8.4.2", + "pytest-playwright>=0.7.1", + "pytest-sugar>=1.1.1", "ruff>=0.14.3", "ty>=0.0.1a25", ] +[build-system] +requires = ["uv_build>=0.9.6,<0.10.0"] +build-backend = "uv_build" + [tool.ruff] target-version = "py314" -src = ["app", "tests"] +src = ["src", "tests"] line-length = 120 indent-width = 4 diff --git a/src/psrt_ghsa_bot/__init__.py b/src/psrt_ghsa_bot/__init__.py new file mode 100644 index 0000000..a781b92 --- /dev/null +++ b/src/psrt_ghsa_bot/__init__.py @@ -0,0 +1,3 @@ +"""PSRT GHSA Bot package.""" + +__version__ = "0.1.0" diff --git a/app.py b/src/psrt_ghsa_bot/app.py similarity index 99% rename from app.py rename to src/psrt_ghsa_bot/app.py index 2fa08ce..9a8fec5 100644 --- a/app.py +++ b/src/psrt_ghsa_bot/app.py @@ -9,7 +9,6 @@ from dotenv import load_dotenv from githubkit import AppAuthStrategy, GitHub -# Load environment variables from .env file load_dotenv() if typing.TYPE_CHECKING: 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/tests/test_app.py similarity index 91% rename from test_app.py rename to tests/test_app.py index 116bfcc..3fae5d4 100644 --- a/test_app.py +++ b/tests/test_app.py @@ -1,9 +1,10 @@ import datetime from unittest import mock -import app import pytest +from psrt_ghsa_bot import app + @pytest.fixture def year() -> str: @@ -50,7 +51,7 @@ def test_adds_psrt_github_team_to_security_advisories(state) -> None: github = mock.Mock() cve_api = mock.Mock() - with mock.patch("app.get_repository_advisories") as get_repo_advs: + with mock.patch("psrt_ghsa_bot.app.get_repository_advisories") as get_repo_advs: get_repo_advs.return_value = [security_advisory] app.apply_to_repo(github, "owner", "repo", cve_api) @@ -74,7 +75,7 @@ def test_appends_psrt_github_team_to_security_advisories(state) -> None: github = mock.Mock() cve_api = mock.Mock() - with mock.patch("app.get_repository_advisories") as get_repo_advs: + with mock.patch("psrt_ghsa_bot.app.get_repository_advisories") as get_repo_advs: get_repo_advs.return_value = [security_advisory] app.apply_to_repo(github, "owner", "repo", cve_api) @@ -94,7 +95,7 @@ def test_does_not_modify_completed_security_advisories(state) -> None: github = mock.Mock() cve_api = mock.Mock() - with mock.patch("app.get_repository_advisories") as get_repo_advs: + with mock.patch("psrt_ghsa_bot.app.get_repository_advisories") as get_repo_advs: get_repo_advs.return_value = [security_advisory] app.apply_to_repo(github, "owner", "repo", cve_api) @@ -113,7 +114,7 @@ def test_reserves_cve_id_for_draft_security_advisories( cve_api = mock.Mock() cve_api.reserve.return_value = cve_reserve_response - with mock.patch("app.get_repository_advisories") as get_repo_advs: + with mock.patch("psrt_ghsa_bot.app.get_repository_advisories") as get_repo_advs: get_repo_advs.return_value = [security_advisory] app.apply_to_repo(github, "owner", "repo", cve_api) @@ -134,7 +135,7 @@ def test_does_not_reserve_cve_id_for_triage_security_advisories(state) -> None: github = mock.Mock() cve_api = mock.Mock() - with mock.patch("app.get_repository_advisories") as get_repo_advs: + with mock.patch("psrt_ghsa_bot.app.get_repository_advisories") as get_repo_advs: get_repo_advs.return_value = [security_advisory] app.apply_to_repo(github, "owner", "repo", cve_api) 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 b4881e63f5e0af1de21811c4342f6ebc4f91825f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:10:24 -0600 Subject: [PATCH 04/29] 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 6cf47e40f92c025a975629c0bec1697d89f79f0d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:10:38 -0600 Subject: [PATCH 05/29] add ci for tests/linting checks --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..84752e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Run ruff lint + uses: astral-sh/ruff-action@v3 + + - name: Run ruff format check + uses: astral-sh/ruff-action@v3 + with: + args: "format --check --diff" + + test: + name: Test on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --locked --dev --no-editable + + - name: Install Playwright browsers + run: uv run playwright install --with-deps chromium + + - name: Run tests + run: uv run pytest tests/ -v --tb=short + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.python-version }} + path: | + .coverage + htmlcov/ + retention-days: 30 \ No newline at end of file From 023d14393f354c412225b1495c0e44597afbf3bf Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Nov 2025 15:10:47 -0600 Subject: [PATCH 06/29] move cron CI to uv --- .github/workflows/cron.yml | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index b31b61c..c218712 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -1,24 +1,32 @@ name: "PSRT GHSA Bot" + on: workflow_dispatch: schedule: - cron: "0 * * * *" + jobs: cron: runs-on: ubuntu-latest - name: "Cron" + name: "Run PSRT Advisory Bot" steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4 + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 with: - python-version: 3.12 - cache: pip - cache-dependency-path: | - requirements.txt - - run: | - python -m pip install -r requirements.txt - - run: | - python app.py + 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: Run bot + run: uv run python src/psrt_ghsa_bot/app.py env: GH_CLIENT_ID: ${{ vars.GH_CLIENT_ID }} GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} From c8b9c8ee8799058291fe4dbc7977dc4949950204 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 6 Nov 2025 15:15:47 -0600 Subject: [PATCH 07/29] 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 680720223e7ea0f9576f5ae389547eeb459f84dc Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 6 Nov 2025 15:27:11 -0600 Subject: [PATCH 08/29] 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 efdfa646ca8713591d2715f469a37cbad7253076 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 7 Nov 2025 11:17:45 -0600 Subject: [PATCH 09/29] 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 236daef433d792ded0044eade91090fd8613ecda Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 7 Nov 2025 11:18:10 -0600 Subject: [PATCH 10/29] 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 818f15678de50257f85604089f63e74db47c21ed Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:04:41 -0600 Subject: [PATCH 11/29] 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 cd318433ce6bebbe1b536827baf8ac47c8d3cfc9 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:04:47 -0600 Subject: [PATCH 12/29] 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 9cba6eb8b48286078bcba316294f77b0e80aea63 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:05:08 -0600 Subject: [PATCH 13/29] 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 8bbbb61e3975f17652760ad22933928227c4df50 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:07:54 -0600 Subject: [PATCH 14/29] 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 6ef021a1eaf2dfdb9eddac08f278530896a668dc Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 12:08:13 -0600 Subject: [PATCH 15/29] 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 7df217f37d2edfa8b254e8bee68dcf7cd6646f2a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:18:24 -0600 Subject: [PATCH 16/29] 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 aee0db6082fc7ea0950a09240fd33379057a4cf4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:18:32 -0600 Subject: [PATCH 17/29] 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 1e080669b56e3cefa086709c0334ad97c3ad26d3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:12 -0600 Subject: [PATCH 18/29] 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 6f87c4887098041795016bc965913f4c2039f502 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:20 -0600 Subject: [PATCH 19/29] 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 9e5e4657e36495a9551c3c5f98f4af718ee55a61 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:27 -0600 Subject: [PATCH 20/29] 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 c010beca09fbbe2e472175872c5be40fb18c1209 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:43 -0600 Subject: [PATCH 21/29] 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 7b427e3b03aecfc697bd4c9c00086337bc3d1ea3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:55 -0600 Subject: [PATCH 22/29] 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 42558b56a7e5987ebce4d729398173f8003134b2 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:19:58 -0600 Subject: [PATCH 23/29] 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 ed7393f513fa7fe1ea44e1fea10362d8307ac7fa Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:20:06 -0600 Subject: [PATCH 24/29] 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 dff3b1af391834ee72ee36cd73f8d7c59a4059cb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 15:25:55 -0600 Subject: [PATCH 25/29] 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 93350efdec3853e570a632cf7e0fc0ddb5454fea Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 19:50:27 -0600 Subject: [PATCH 26/29] 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 f6ab771fbbf13bc0560a35ffde39155e09b884a4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 20:18:58 -0600 Subject: [PATCH 27/29] 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 2ebdb8afafa361e24164e18a2574cf7225484aca Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 20:58:12 -0600 Subject: [PATCH 28/29] 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 628fd7a9d33166539a33cb1a5891c5670d46a6b6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 19 Nov 2025 20:58:25 -0600 Subject: [PATCH 29/29] 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