From 2224e0c858ca1b6c083166958d260395f9ee894e Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:14:35 -0600 Subject: [PATCH 1/8] Added PyGithub package to invoke github api calls 1- Except get_file the rest use the package 2- get_file uses requests since iter_chunks helps with reading large files and using the package wasn't straightforward to handle large files Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- diff_poetry_lock/github.py | 117 +++++++------------ diff_poetry_lock/run_poetry.py | 2 +- diff_poetry_lock/test/test_poetry_diff.py | 136 +++++++++++++++------- poetry.lock | 93 ++++++++++++++- pyproject.toml | 1 + 5 files changed, 225 insertions(+), 124 deletions(-) diff --git a/diff_poetry_lock/github.py b/diff_poetry_lock/github.py index 17cfc7a..fdb280a 100644 --- a/diff_poetry_lock/github.py +++ b/diff_poetry_lock/github.py @@ -1,7 +1,7 @@ -from enum import Enum -from urllib.parse import urlparse - import requests +from github import Auth, Github +from github.GithubException import GithubException +from github.Repository import Repository from loguru import logger from pydantic import BaseModel, Field, parse_obj_as from requests import Response @@ -34,10 +34,19 @@ class GithubApi: def __init__(self, settings: Settings) -> None: self.s = settings self.session = requests.session() + self.github = Github(auth=Auth.Token(self.s.token), base_url=self.s.api_url.rstrip("/"), per_page=100) + self._repo: Repository | None = None + self.requester = self.github._Github__requester self._ref_hash_cache: dict[str, str] = {} if isinstance(self.s, PrLookupConfigurable): self.s.set_pr_lookup_service(self) + @property + def repo(self) -> Repository: + if self._repo is None: + self._repo = self.github.get_repo(self.s.repository) + return self._repo + def post_comment(self, comment: str) -> None: if not comment: logger.info("No changes to lockfile detected") @@ -48,25 +57,18 @@ def post_comment(self, comment: str) -> None: return logger.debug("Posting comment to PR #{}", self.s.pr_num) - r = self.session.post( - f"{self.s.api_url}/repos/{self.s.repository}/issues/{self.s.pr_num}/comments", - headers=GithubApi.Headers.JSON.headers(self.s.token), - json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{comment}"}, - timeout=10, - ) - logger.debug("Response status: {}", r.status_code) - r.raise_for_status() + issue = self.repo.get_issue(int(self.s.pr_num)) + issue.create_comment(f"{MAGIC_COMMENT_IDENTIFIER}{comment}") def update_comment(self, comment_id: int, comment: str) -> None: logger.debug("Updating comment {}", comment_id) - r = self.session.patch( - f"{self.s.api_url}/repos/{self.s.repository}/issues/comments/{comment_id}", - headers=GithubApi.Headers.JSON.headers(self.s.token), - json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{comment}"}, - timeout=10, - ) - logger.debug("Response status: {}", r.status_code) - r.raise_for_status() + if not self.s.pr_num: + logger.warning("No PR number available; skipping comment update") + return + + issue = self.repo.get_issue(int(self.s.pr_num)) + issue_comment = issue.get_comment(comment_id) + issue_comment.edit(f"{MAGIC_COMMENT_IDENTIFIER}{comment}") def list_comments(self) -> list[GithubComment]: if not self.s.pr_num: @@ -74,18 +76,13 @@ def list_comments(self) -> list[GithubComment]: return [] logger.debug("Fetching comments for PR #{}", self.s.pr_num) - all_comments, comments, page = [], None, 1 - while comments is None or len(comments) == 100: - r = self.session.get( - f"{self.s.api_url}/repos/{self.s.repository}/issues/{self.s.pr_num}/comments", - params={"per_page": 100, "page": page}, - headers=GithubApi.Headers.JSON.headers(self.s.token), - timeout=10, - ) - r.raise_for_status() - comments = parse_obj_as(list[GithubComment], r.json()) - all_comments.extend(comments) - page += 1 + issue = self.repo.get_issue(int(self.s.pr_num)) + parsed_payload: list[dict[str, object]] = [] + for comment in issue.get_comments(): + user_id = comment.user.id if comment.user is not None else 0 + parsed_payload.append({"id": comment.id, "body": comment.body or "", "user": {"id": user_id}}) + + all_comments = parse_obj_as(list[GithubComment], parsed_payload) logger.debug("Found %d comments", len(all_comments)) return [c for c in all_comments if c.is_diff_comment()] @@ -95,7 +92,7 @@ def get_file(self, ref: str) -> Response: r = self.session.get( f"{self.s.api_url}/repos/{self.s.repository}/contents/{self.s.lockfile_path}", params={"ref": ref}, - headers=GithubApi.Headers.RAW.headers(self.s.token), + headers={"Authorization": f"Bearer {self.s.token}", "Accept": "application/vnd.github.raw"}, timeout=10, stream=True, ) @@ -130,15 +127,7 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] } try: - r = self.session.post( - self.graphql_url(), - headers=GithubApi.Headers.JSON.headers(self.s.token), - json={"query": query, "variables": variables}, - timeout=10, - ) - logger.debug("GraphQL response status: {}", r.status_code) - r.raise_for_status() - response_json = r.json() + _headers, response_json = self.requester.graphql_query(query, variables) repo_data = response_json.get("data", {}).get("repository", {}) resolved_head_hash = str(get_nested(repo_data, ("head", "target", "oid")) or "").strip() @@ -148,7 +137,7 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] if resolved_base_hash: self._ref_hash_cache[base_ref] = resolved_base_hash - except (requests.RequestException, ValueError, TypeError): + except (GithubException, ValueError, TypeError): logger.exception("Failed to resolve commit hashes via GraphQL") resolved_head_hash = self._ref_hash_cache.get(head_ref, head_ref) @@ -157,14 +146,6 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] logger.warning("Could not resolve one or more commit hashes, falling back to provided refs") return resolved_head_hash, resolved_base_hash - def graphql_url(self) -> str: - parsed = urlparse(self.s.api_url) - if parsed.path.endswith("/api/v3"): - graphql_path = f"{parsed.path.removesuffix('/api/v3')}/api/graphql" - return f"{parsed.scheme}://{parsed.netloc}{graphql_path}" - - return f"{self.s.api_url.rstrip('/')}/graphql" - @staticmethod def _qualified_ref(ref: str) -> str: if ref.startswith("refs/"): @@ -173,12 +154,13 @@ def _qualified_ref(ref: str) -> str: def delete_comment(self, comment_id: int) -> None: logger.debug("Deleting comment {}", comment_id) - r = self.session.delete( - f"{self.s.api_url}/repos/{self.s.repository}/issues/comments/{comment_id}", - headers=GithubApi.Headers.JSON.headers(self.s.token), - ) - logger.debug("Response status: {}", r.status_code) - r.raise_for_status() + if not self.s.pr_num: + logger.warning("No PR number available; skipping comment delete") + return + + issue = self.repo.get_issue(int(self.s.pr_num)) + issue_comment = issue.get_comment(comment_id) + issue_comment.delete() def find_pr_for_branch(self, branch_ref: str) -> str: """Find open PR number for a given branch ref (e.g., 'refs/heads/deps-update'). @@ -189,33 +171,16 @@ def find_pr_for_branch(self, branch_ref: str) -> str: org = self.s.repository.split("/")[0] head = f"{org}:{branch}" - r = self.session.get( - f"{self.s.api_url}/repos/{self.s.repository}/pulls", - params={"head": head, "state": "open"}, - headers=GithubApi.Headers.JSON.headers(self.s.token), - timeout=10, - ) - logger.debug("Response status: {}", r.status_code) - r.raise_for_status() + pulls = self.repo.get_pulls(state="open", head=head) - pulls = r.json() - if pulls and len(pulls) > 0: - pr_num = str(pulls[0]["number"]) + if pulls.totalCount > 0: + pr_num = str(next(iter(pulls)).number) logger.debug("Found open PR #{}", pr_num) return pr_num logger.debug("No open PR found for branch {}", branch) return "" - class Headers(Enum): - """Enum for github api content types.""" - - JSON = "application/vnd.github+json" - RAW = "application/vnd.github.raw" - - def headers(self, token: str) -> dict[str, str]: - return {"Authorization": f"Bearer {token}", "Accept": self.value} - def upsert_comment(self, existing_comment: GithubComment | None, comment: str | None) -> None: if existing_comment is None and comment is None: return diff --git a/diff_poetry_lock/run_poetry.py b/diff_poetry_lock/run_poetry.py index b44ad56..299c97a 100644 --- a/diff_poetry_lock/run_poetry.py +++ b/diff_poetry_lock/run_poetry.py @@ -14,7 +14,7 @@ def load_packages(filename: Path = Path("poetry.lock")) -> list[Package]: - l_merged = Locker(Path(filename), pyproject_data={}) + l_merged = Locker(Path(filename), {}) return l_merged.locked_repository().packages diff --git a/diff_poetry_lock/test/test_poetry_diff.py b/diff_poetry_lock/test/test_poetry_diff.py index 0e9b168..65ddc9f 100644 --- a/diff_poetry_lock/test/test_poetry_diff.py +++ b/diff_poetry_lock/test/test_poetry_diff.py @@ -1,4 +1,3 @@ -import os from operator import attrgetter from pathlib import Path from textwrap import dedent @@ -155,32 +154,6 @@ def test_diff() -> None: ).strip() == dedent(expected_comment).strip() -def test_request_headers_method() -> None: - headers = GithubApi.Headers.JSON.headers("sekret-token") - assert headers["Authorization"] == "Bearer sekret-token" - assert headers["Accept"] == "application/vnd.github+json" - - -def graphql_url_examples() -> list[tuple[str, str]]: - examples: list[tuple[str, str]] = [("https://api.github.com", "https://api.github.com/graphql")] - if ghes := os.environ.get("PARAMETER_GITHUB_API_URL"): - replaced = ghes.replace("v3", "graphql") - if replaced != ghes: - examples.append((ghes, replaced)) - return examples - - -@pytest.mark.parametrize( - ("api_url", "expected_graphql_url"), - graphql_url_examples(), -) -def test_graphql_url_resolution(api_url: str, expected_graphql_url: str) -> None: - cfg = create_settings(api_url=api_url) - api = GithubApi(cfg) - - assert api.graphql_url() == expected_graphql_url - - def test_diff_2() -> None: old = load_packages(TESTFILE_2) new = load_packages(TESTFILE_1) @@ -240,9 +213,9 @@ def test_diff_no_changes() -> None: def test_file_loading_missing_file_base_ref(cfg: Settings) -> None: with requests_mock.Mocker() as m: + mock_repo(m, cfg) m.get( f"{cfg.api_url}/repos/{cfg.repository}/contents/{cfg.lockfile_path}?ref={cfg.base_ref}", - request_headers=GithubApi.Headers.RAW.headers(cfg.token), status_code=404, ) @@ -252,10 +225,10 @@ def test_file_loading_missing_file_base_ref(cfg: Settings) -> None: def test_file_loading_missing_file_head_ref(cfg: Settings, data1: bytes) -> None: with requests_mock.Mocker() as m: + mock_repo(m, cfg) mock_get_file(m, cfg, data1, cfg.base_ref) m.get( f"{cfg.api_url}/repos/{cfg.repository}/contents/{cfg.lockfile_path}?ref={cfg.ref}", - request_headers=GithubApi.Headers.RAW.headers(cfg.token), status_code=404, ) @@ -273,9 +246,9 @@ def test_e2e_no_diff_existing_comment(cfg: Settings, data1: bytes) -> None: {"body": f"{MAGIC_COMMENT_IDENTIFIER}foobar", "id": 1337, "user": {"id": 41898282}}, ] mock_list_comments(m, cfg, comments) + mock_issue_comment(m, cfg, 1337, f"{MAGIC_COMMENT_IDENTIFIER}foobar") m.delete( f"{cfg.api_url}/repos/{cfg.repository}/issues/comments/1337", - headers=GithubApi.Headers.JSON.headers(cfg.token), ) do_diff(cfg) @@ -305,7 +278,6 @@ def test_e2e_diff_inexisting_comment(cfg: Settings, data1: bytes, data2: bytes) mock_list_comments(m, cfg, [], pr_num="1") m.post( f"{cfg.api_url}/repos/{cfg.repository}/issues/1/comments", - headers=GithubApi.Headers.JSON.headers(cfg.token), json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{summary}"}, ) @@ -352,9 +324,9 @@ def test_e2e_diff_existing_comment_different_data(cfg: Settings, data1: bytes, d {"body": f"{MAGIC_COMMENT_IDENTIFIER}{summary}", "id": 1337, "user": {"id": 41898282}}, ] mock_list_comments(m, cfg, comments, pr_num="1") + mock_issue_comment(m, cfg, 1337, f"{MAGIC_COMMENT_IDENTIFIER}{summary}") m.patch( f"{cfg.api_url}/repos/{cfg.repository}/issues/comments/1337", - headers=GithubApi.Headers.JSON.headers(cfg.token), json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{summary}"}, ) @@ -374,45 +346,89 @@ def test_e2e_diff_commit_lookup_http_failure_falls_back_to_ref(cfg: Settings, da mock_find_pr_for_branch(m, cfg, pr_num="1") m.post( f"{cfg.api_url}/graphql", - request_headers=GithubApi.Headers.JSON.headers(cfg.token), status_code=500, ) mock_list_comments(m, cfg, [], pr_num="1") m.post( f"{cfg.api_url}/repos/{cfg.repository}/issues/1/comments", - headers=GithubApi.Headers.JSON.headers(cfg.token), json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{summary}"}, ) do_diff(cfg) +def test_resolve_commit_hash_request_exception_returns_ref(cfg: Settings, monkeypatch: MonkeyPatch) -> None: + api = GithubApi(cfg) + + def raise_timeout(*_args: object, **_kwargs: object) -> None: + msg = "timeout" + raise ValueError(msg) + + monkeypatch.setattr(api.requester, "graphql_query", raise_timeout) + + resolved_head, resolved_base = api.resolve_commit_hashes(cfg.ref, cfg.base_ref) + assert resolved_head == cfg.ref + assert resolved_base == cfg.base_ref + + +def test_resolve_commit_hash_cache_hit_uses_cached_value(cfg: Settings) -> None: + api = GithubApi(cfg) + + with requests_mock.Mocker() as m: + mock_resolve_commit_hashes(m, cfg, head_hash="cached-sha", base_hash="base-sha") + resolved_head, resolved_base = api.resolve_commit_hashes(cfg.ref, cfg.base_ref) + + assert resolved_head == "cached-sha" + assert resolved_base == "base-sha" + + +def test_resolve_commit_hash_cache_miss_returns_ref(cfg: Settings) -> None: + api = GithubApi(cfg) + + with requests_mock.Mocker() as m: + mock_resolve_commit_hashes(m, cfg) + resolved_head, resolved_base = api.resolve_commit_hashes(cfg.ref, cfg.base_ref) + assert resolved_head == cfg.ref + assert resolved_base == cfg.base_ref + + def load_file(filename: Path) -> bytes: with filename.open("rb") as f: return f.read() def mock_find_pr_for_branch(m: Mocker, s: Settings, pr_num: str = "1") -> None: - head = f"{s.repository.split('/')[0]}:{s.ref}" + mock_repo(m, s) + branch = s.ref.replace("refs/heads/", "") + head = f"{s.repository.split('/')[0]}:{branch}" m.get( f"{s.api_url}/repos/{s.repository}/pulls?head={head}&state=open", - request_headers=GithubApi.Headers.JSON.headers(s.token), + json=[{"number": int(pr_num)}], + ) + m.get( + f"{s.api_url}/repos/{s.repository}/pulls?state=open&head={head}", json=[{"number": int(pr_num)}], ) def mock_list_comments(m: Mocker, s: Settings, response_json: list[dict[Any, Any]], pr_num: str = "1") -> None: + mock_repo(m, s) + mock_issue(m, s, pr_num=pr_num) + comments_base_url = f"{s.api_url}/repos/{s.repository}/issues/{pr_num}/comments" m.get( - f"{s.api_url}/repos/{s.repository}/issues/{pr_num}/comments?per_page=100&page=1", - request_headers=GithubApi.Headers.JSON.headers(s.token), + f"{comments_base_url}?per_page=100&page=1", + json=response_json, + ) + m.get( + f"{comments_base_url}?per_page=100", json=response_json, ) def mock_get_file(m: Mocker, s: Settings, data: bytes, ref: str, resolved_hash: str | None = None) -> None: + mock_repo(m, s) m.get( f"{s.api_url}/repos/{s.repository}/contents/{s.lockfile_path}?ref={ref}", - request_headers=GithubApi.Headers.RAW.headers(s.token), content=data, ) @@ -433,16 +449,54 @@ def mock_resolve_commit_hashes( } m.post( f"{s.api_url}/graphql", - request_headers=GithubApi.Headers.JSON.headers(s.token), json=response_json, ) +def mock_repo(m: Mocker, s: Settings) -> None: + repo_url = f"{s.api_url}/repos/{s.repository}" + m.get( + repo_url, + json={ + "id": 1, + "name": s.repository.split("/")[-1], + "full_name": s.repository, + "url": repo_url, + "contents_url": f"{repo_url}/contents/{{+path}}", + "issues_url": f"{repo_url}/issues{{/number}}", + "pulls_url": f"{repo_url}/pulls{{/number}}", + }, + ) + + +def mock_issue(m: Mocker, s: Settings, pr_num: str | None = None) -> None: + effective_pr_num = pr_num or s.pr_num + assert effective_pr_num is not None + issue_url = f"{s.api_url}/repos/{s.repository}/issues/{effective_pr_num}" + m.get( + issue_url, + json={ + "id": int(effective_pr_num), + "number": int(effective_pr_num), + "url": issue_url, + "comments_url": f"{issue_url}/comments", + }, + ) + + +def mock_issue_comment(m: Mocker, s: Settings, comment_id: int, body: str) -> None: + comment_url = f"{s.api_url}/repos/{s.repository}/issues/comments/{comment_id}" + m.get( + comment_url, + json={"id": comment_id, "url": comment_url, "body": body, "user": {"id": 41898282}}, + ) + + def create_settings( repository: str = "user/repo", lockfile_path: str = "poetry.lock", token: str = "foobar", - api_url: str = "http://localhost/github_api", + api_url: str = "http://localhost:80", ) -> Settings: return GitHubActionsSettings( event_name="pull_request", diff --git a/poetry.lock b/poetry.lock index 7160a53..d1d01ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "anyio" @@ -101,7 +101,7 @@ description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and sys_platform == \"linux\" or sys_platform == \"darwin\"" +markers = "sys_platform == \"darwin\" or platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -363,7 +363,6 @@ description = "cryptography is a package which provides cryptographic recipes an optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main"] -markers = "sys_platform == \"linux\"" files = [ {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, @@ -919,7 +918,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] [[package]] name = "more-itertools" @@ -1228,7 +1227,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "(platform_python_implementation != \"PyPy\" and sys_platform == \"linux\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\"" +markers = "(sys_platform == \"darwin\" or platform_python_implementation != \"PyPy\") and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, @@ -1288,6 +1287,25 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] +[[package]] +name = "pygithub" +version = "2.8.1" +description = "Use the full Github API v3" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0"}, + {file = "pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9"}, +] + +[package.dependencies] +pyjwt = {version = ">=2.4.0", extras = ["crypto"]} +pynacl = ">=1.4.0" +requests = ">=2.14.0" +typing-extensions = ">=4.5.0" +urllib3 = ">=1.26.0" + [[package]] name = "pygments" version = "2.19.2" @@ -1303,6 +1321,69 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.11.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, + {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] + +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -1945,4 +2026,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.15" -content-hash = "e254b9dbcf64849a73d804892eb46064848c3597f03b0976a44e0f0aba585227" +content-hash = "fc144774438c296eaabb0b9a1cd59c321c0deaa1f6d7db2d45c67815d40b67c4" diff --git a/pyproject.toml b/pyproject.toml index 0bd7891..4287f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ poetry = "^2" requests = "2.32.4" pydantic = "^1.10.6" loguru = "^0.7.3" +PyGithub = "^2.8.1" [tool.poetry.group.dev.dependencies] pytest = "^9" From fc18620700eef663a95e41032a9e44c77fe9ed93 Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:50:06 -0600 Subject: [PATCH 2/8] Fixed commit hash api call Replaced graphql api call with vanilla requests call since using the package doesn't have a value add Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- diff_poetry_lock/github.py | 40 +++++++++++++++++++--- diff_poetry_lock/test/test_poetry_diff.py | 41 ++++++++++++++++++++++- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/diff_poetry_lock/github.py b/diff_poetry_lock/github.py index fdb280a..9f5d0b5 100644 --- a/diff_poetry_lock/github.py +++ b/diff_poetry_lock/github.py @@ -1,6 +1,8 @@ +from enum import Enum +from urllib.parse import urlparse + import requests from github import Auth, Github -from github.GithubException import GithubException from github.Repository import Repository from loguru import logger from pydantic import BaseModel, Field, parse_obj_as @@ -33,11 +35,14 @@ def __init__(self, repo: str, branch: str) -> None: class GithubApi: def __init__(self, settings: Settings) -> None: self.s = settings + self.session = requests.session() + self.github = Github(auth=Auth.Token(self.s.token), base_url=self.s.api_url.rstrip("/"), per_page=100) self._repo: Repository | None = None - self.requester = self.github._Github__requester + self._ref_hash_cache: dict[str, str] = {} + if isinstance(self.s, PrLookupConfigurable): self.s.set_pr_lookup_service(self) @@ -92,7 +97,7 @@ def get_file(self, ref: str) -> Response: r = self.session.get( f"{self.s.api_url}/repos/{self.s.repository}/contents/{self.s.lockfile_path}", params={"ref": ref}, - headers={"Authorization": f"Bearer {self.s.token}", "Accept": "application/vnd.github.raw"}, + headers=GithubApi.Headers.RAW.headers(self.s.token), timeout=10, stream=True, ) @@ -127,7 +132,15 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] } try: - _headers, response_json = self.requester.graphql_query(query, variables) + r = self.session.post( + self.graphql_url(), + headers=GithubApi.Headers.JSON.headers(self.s.token), + json={"query": query, "variables": variables}, + timeout=10, + ) + logger.debug("GraphQL response status: {}", r.status_code) + r.raise_for_status() + response_json = r.json() repo_data = response_json.get("data", {}).get("repository", {}) resolved_head_hash = str(get_nested(repo_data, ("head", "target", "oid")) or "").strip() @@ -137,7 +150,7 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] if resolved_base_hash: self._ref_hash_cache[base_ref] = resolved_base_hash - except (GithubException, ValueError, TypeError): + except (requests.RequestException, ValueError, TypeError): logger.exception("Failed to resolve commit hashes via GraphQL") resolved_head_hash = self._ref_hash_cache.get(head_ref, head_ref) @@ -146,6 +159,14 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] logger.warning("Could not resolve one or more commit hashes, falling back to provided refs") return resolved_head_hash, resolved_base_hash + def graphql_url(self) -> str: + parsed = urlparse(self.s.api_url) + if parsed.path.endswith("/api/v3"): + graphql_path = f"{parsed.path.removesuffix('/api/v3')}/api/graphql" + return f"{parsed.scheme}://{parsed.netloc}{graphql_path}" + + return f"{self.s.api_url.rstrip('/')}/graphql" + @staticmethod def _qualified_ref(ref: str) -> str: if ref.startswith("refs/"): @@ -162,6 +183,15 @@ def delete_comment(self, comment_id: int) -> None: issue_comment = issue.get_comment(comment_id) issue_comment.delete() + class Headers(Enum): + """Enum for github api headers.""" + + JSON = "application/vnd.github+json" + RAW = "application/vnd.github.raw" + + def headers(self, token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}", "Accept": self.value} + def find_pr_for_branch(self, branch_ref: str) -> str: """Find open PR number for a given branch ref (e.g., 'refs/heads/deps-update'). Returns PR number as string, or empty string if not found.""" diff --git a/diff_poetry_lock/test/test_poetry_diff.py b/diff_poetry_lock/test/test_poetry_diff.py index 65ddc9f..2d00e1f 100644 --- a/diff_poetry_lock/test/test_poetry_diff.py +++ b/diff_poetry_lock/test/test_poetry_diff.py @@ -1,3 +1,5 @@ +import os +from collections.abc import Callable from operator import attrgetter from pathlib import Path from textwrap import dedent @@ -114,6 +116,43 @@ def test_settings_not_pr(monkeypatch: MonkeyPatch) -> None: assert pytest_wrapped_e.value.code == 0 +def accept_headers_examples() -> list[tuple[Callable[[str], dict[str, str]], str]]: + return [ + (GithubApi.Headers.JSON.headers, "application/vnd.github+json"), + (GithubApi.Headers.RAW.headers, "application/vnd.github.raw"), + ] + + +@pytest.mark.parametrize( + ("headers_fn", "expected_accept_header"), + accept_headers_examples(), +) +def test_request_headers_method(headers_fn: Callable[[str], dict[str, str]], expected_accept_header: str) -> None: + headers = headers_fn("sekret-token") + assert headers["Authorization"] == "Bearer sekret-token" + assert headers["Accept"] == expected_accept_header + + +def graphql_url_examples() -> list[tuple[str, str]]: + examples: list[tuple[str, str]] = [("https://api.github.com", "https://api.github.com/graphql")] + if ghes := os.environ.get("PARAMETER_GITHUB_API_URL"): + replaced = ghes.replace("v3", "graphql") + if replaced != ghes: + examples.append((ghes, replaced)) + return examples + + +@pytest.mark.parametrize( + ("api_url", "expected_graphql_url"), + graphql_url_examples(), +) +def test_graphql_url_resolution(api_url: str, expected_graphql_url: str) -> None: + cfg = create_settings(api_url=api_url) + api = GithubApi(cfg) + + assert api.graphql_url() == expected_graphql_url + + def test_diff() -> None: old = load_packages(TESTFILE_1) new = load_packages(TESTFILE_2) @@ -364,7 +403,7 @@ def raise_timeout(*_args: object, **_kwargs: object) -> None: msg = "timeout" raise ValueError(msg) - monkeypatch.setattr(api.requester, "graphql_query", raise_timeout) + monkeypatch.setattr("diff_poetry_lock.github.requests.post", raise_timeout) resolved_head, resolved_base = api.resolve_commit_hashes(cfg.ref, cfg.base_ref) assert resolved_head == cfg.ref From d5ba3597dce66d6090b1affa9701f9a5bd7e2bde Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:01:25 -0600 Subject: [PATCH 3/8] Adding a ref to Dogfooding main action Currently, all changes are not being read by the action so fixing it by adding the ref with a depth of 0 to get all commits in the pr branch to checkout and run the tool Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- .github/workflows/test_and_lint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index 401b97e..1e9e2db 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -49,6 +49,8 @@ jobs: if: '!inputs.skip-checkout' uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 persist-credentials: false - name: Diff poetry.lock uses: | # zizmor: ignore[unpinned-uses] It's safe to use main on our own repo. From 7fd82c02f7658cfe3bb0909a6407816d32a3b38b Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:23:07 -0600 Subject: [PATCH 4/8] Attempting to fix Dogfooding main action Fixed the action to checkout all changes to the poetry.lock file alone in pr and run the tool against the main branch Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- .github/workflows/test_and_lint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index 1e9e2db..e98573f 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -51,6 +51,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 + sparse-checkout: poetry.lock persist-credentials: false - name: Diff poetry.lock uses: | # zizmor: ignore[unpinned-uses] It's safe to use main on our own repo. From 2fa93a9c147f3291fc605dd2b5825bcd5e8068f8 Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:51:55 -0600 Subject: [PATCH 5/8] Enabling debug mode for tool in Dogfooding main action Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- .github/workflows/test_and_lint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index e98573f..ce78040 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -54,5 +54,7 @@ jobs: sparse-checkout: poetry.lock persist-credentials: false - name: Diff poetry.lock + env: + DEBUG_MODE: 1 uses: | # zizmor: ignore[unpinned-uses] It's safe to use main on our own repo. target/diff-poetry-lock@main From 01d4c2d59d469478e1a87ddffc539a9a5de82553 Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:56:23 -0500 Subject: [PATCH 6/8] Replaced github api calls with more of the package api invokers 1- Remaining get_file call that still relies on requests might be addressed in a future pull request Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- diff_poetry_lock/github.py | 94 ++++++++++++----------- diff_poetry_lock/test/test_poetry_diff.py | 48 ++---------- 2 files changed, 53 insertions(+), 89 deletions(-) diff --git a/diff_poetry_lock/github.py b/diff_poetry_lock/github.py index 9f5d0b5..479c23a 100644 --- a/diff_poetry_lock/github.py +++ b/diff_poetry_lock/github.py @@ -3,9 +3,11 @@ import requests from github import Auth, Github +from github.GithubException import GithubException +from github.Issue import Issue +from github.IssueComment import IssueComment from github.Repository import Repository from loguru import logger -from pydantic import BaseModel, Field, parse_obj_as from requests import Response from diff_poetry_lock.settings import PrLookupConfigurable, Settings @@ -14,18 +16,6 @@ MAGIC_COMMENT_IDENTIFIER = "\n\n" -class GithubComment(BaseModel): - class GithubUser(BaseModel): - id_: int = Field(alias="id") - - body: str - id_: int = Field(alias="id") - user: GithubUser - - def is_diff_comment(self) -> bool: - return self.body.startswith(MAGIC_COMMENT_IDENTIFIER) - - class RepoFileRetrievalError(BaseException): def __init__(self, repo: str, branch: str) -> None: msg = f"Error accessing a file in repo [{repo}] on branch [{branch}]" @@ -35,12 +25,10 @@ def __init__(self, repo: str, branch: str) -> None: class GithubApi: def __init__(self, settings: Settings) -> None: self.s = settings - self.session = requests.session() - self.github = Github(auth=Auth.Token(self.s.token), base_url=self.s.api_url.rstrip("/"), per_page=100) self._repo: Repository | None = None - + self.requester = self.github.requester self._ref_hash_cache: dict[str, str] = {} if isinstance(self.s, PrLookupConfigurable): @@ -52,6 +40,12 @@ def repo(self) -> Repository: self._repo = self.github.get_repo(self.s.repository) return self._repo + def build_issue_comment_url(self, comment_id: int) -> str: + return f"/repos/{self.s.repository}/issues/comments/{comment_id}" + + def build_issue_url(self) -> str: + return f"/repos/{self.s.repository}/issues/{self.s.pr_num}" + def post_comment(self, comment: str) -> None: if not comment: logger.info("No changes to lockfile detected") @@ -62,8 +56,13 @@ def post_comment(self, comment: str) -> None: return logger.debug("Posting comment to PR #{}", self.s.pr_num) - issue = self.repo.get_issue(int(self.s.pr_num)) - issue.create_comment(f"{MAGIC_COMMENT_IDENTIFIER}{comment}") + + issue = Issue( + requester=self.requester, + url=self.build_issue_url(), + ) + + issue.create_comment(body=f"{MAGIC_COMMENT_IDENTIFIER}{comment}") def update_comment(self, comment_id: int, comment: str) -> None: logger.debug("Updating comment {}", comment_id) @@ -71,25 +70,33 @@ def update_comment(self, comment_id: int, comment: str) -> None: logger.warning("No PR number available; skipping comment update") return - issue = self.repo.get_issue(int(self.s.pr_num)) - issue_comment = issue.get_comment(comment_id) - issue_comment.edit(f"{MAGIC_COMMENT_IDENTIFIER}{comment}") + issue_comment = IssueComment( + requester=self.requester, + url=self.build_issue_comment_url(comment_id), + ) + + issue_comment.edit(body=f"{MAGIC_COMMENT_IDENTIFIER}{comment}") - def list_comments(self) -> list[GithubComment]: + def list_comments(self) -> list[IssueComment]: if not self.s.pr_num: logger.warning("No PR number available; returning empty comment list") return [] logger.debug("Fetching comments for PR #{}", self.s.pr_num) - issue = self.repo.get_issue(int(self.s.pr_num)) - parsed_payload: list[dict[str, object]] = [] - for comment in issue.get_comments(): - user_id = comment.user.id if comment.user is not None else 0 - parsed_payload.append({"id": comment.id, "body": comment.body or "", "user": {"id": user_id}}) - all_comments = parse_obj_as(list[GithubComment], parsed_payload) - logger.debug("Found %d comments", len(all_comments)) - return [c for c in all_comments if c.is_diff_comment()] + issue = Issue( + requester=self.requester, + url=self.build_issue_url(), + ) + + all_comments = issue.get_comments() + + logger.debug("Found %d comments", all_comments.totalCount) + + def is_diff_comment(comment: IssueComment) -> bool: + return comment.body.startswith(MAGIC_COMMENT_IDENTIFIER) + + return [c for c in all_comments if is_diff_comment(c)] def get_file(self, ref: str) -> Response: logger.debug("Fetching {} from ref {}", self.s.lockfile_path, ref) @@ -97,7 +104,7 @@ def get_file(self, ref: str) -> Response: r = self.session.get( f"{self.s.api_url}/repos/{self.s.repository}/contents/{self.s.lockfile_path}", params={"ref": ref}, - headers=GithubApi.Headers.RAW.headers(self.s.token), + headers={"Authorization": self.s.token, "Accept": "application/vnd.github.raw"}, timeout=10, stream=True, ) @@ -132,15 +139,7 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] } try: - r = self.session.post( - self.graphql_url(), - headers=GithubApi.Headers.JSON.headers(self.s.token), - json={"query": query, "variables": variables}, - timeout=10, - ) - logger.debug("GraphQL response status: {}", r.status_code) - r.raise_for_status() - response_json = r.json() + _, response_json = self.requester.graphql_query(query, variables) repo_data = response_json.get("data", {}).get("repository", {}) resolved_head_hash = str(get_nested(repo_data, ("head", "target", "oid")) or "").strip() @@ -150,7 +149,7 @@ def resolve_commit_hashes(self, head_ref: str, base_ref: str) -> tuple[str, str] if resolved_base_hash: self._ref_hash_cache[base_ref] = resolved_base_hash - except (requests.RequestException, ValueError, TypeError): + except (GithubException, ValueError, TypeError): logger.exception("Failed to resolve commit hashes via GraphQL") resolved_head_hash = self._ref_hash_cache.get(head_ref, head_ref) @@ -179,8 +178,11 @@ def delete_comment(self, comment_id: int) -> None: logger.warning("No PR number available; skipping comment delete") return - issue = self.repo.get_issue(int(self.s.pr_num)) - issue_comment = issue.get_comment(comment_id) + issue_comment = IssueComment( + requester=self.requester, + url=self.build_issue_comment_url(comment_id), + ) + issue_comment.delete() class Headers(Enum): @@ -211,7 +213,7 @@ def find_pr_for_branch(self, branch_ref: str) -> str: logger.debug("No open PR found for branch {}", branch) return "" - def upsert_comment(self, existing_comment: GithubComment | None, comment: str | None) -> None: + def upsert_comment(self, existing_comment: IssueComment | None, comment: str | None) -> None: if existing_comment is None and comment is None: return @@ -221,11 +223,11 @@ def upsert_comment(self, existing_comment: GithubComment | None, comment: str | elif existing_comment is not None and comment is None: logger.info("Deleting existing comment.") - self.delete_comment(existing_comment.id_) + self.delete_comment(existing_comment.id) elif existing_comment is not None and comment is not None: if existing_comment.body == f"{MAGIC_COMMENT_IDENTIFIER}{comment}": logger.debug("Content did not change, not updating existing comment.") else: logger.info("Updating existing comment.") - self.update_comment(existing_comment.id_, comment) + self.update_comment(existing_comment.id, comment) diff --git a/diff_poetry_lock/test/test_poetry_diff.py b/diff_poetry_lock/test/test_poetry_diff.py index 2d00e1f..8b25d63 100644 --- a/diff_poetry_lock/test/test_poetry_diff.py +++ b/diff_poetry_lock/test/test_poetry_diff.py @@ -1,5 +1,3 @@ -import os -from collections.abc import Callable from operator import attrgetter from pathlib import Path from textwrap import dedent @@ -116,43 +114,6 @@ def test_settings_not_pr(monkeypatch: MonkeyPatch) -> None: assert pytest_wrapped_e.value.code == 0 -def accept_headers_examples() -> list[tuple[Callable[[str], dict[str, str]], str]]: - return [ - (GithubApi.Headers.JSON.headers, "application/vnd.github+json"), - (GithubApi.Headers.RAW.headers, "application/vnd.github.raw"), - ] - - -@pytest.mark.parametrize( - ("headers_fn", "expected_accept_header"), - accept_headers_examples(), -) -def test_request_headers_method(headers_fn: Callable[[str], dict[str, str]], expected_accept_header: str) -> None: - headers = headers_fn("sekret-token") - assert headers["Authorization"] == "Bearer sekret-token" - assert headers["Accept"] == expected_accept_header - - -def graphql_url_examples() -> list[tuple[str, str]]: - examples: list[tuple[str, str]] = [("https://api.github.com", "https://api.github.com/graphql")] - if ghes := os.environ.get("PARAMETER_GITHUB_API_URL"): - replaced = ghes.replace("v3", "graphql") - if replaced != ghes: - examples.append((ghes, replaced)) - return examples - - -@pytest.mark.parametrize( - ("api_url", "expected_graphql_url"), - graphql_url_examples(), -) -def test_graphql_url_resolution(api_url: str, expected_graphql_url: str) -> None: - cfg = create_settings(api_url=api_url) - api = GithubApi(cfg) - - assert api.graphql_url() == expected_graphql_url - - def test_diff() -> None: old = load_packages(TESTFILE_1) new = load_packages(TESTFILE_2) @@ -252,7 +213,6 @@ def test_diff_no_changes() -> None: def test_file_loading_missing_file_base_ref(cfg: Settings) -> None: with requests_mock.Mocker() as m: - mock_repo(m, cfg) m.get( f"{cfg.api_url}/repos/{cfg.repository}/contents/{cfg.lockfile_path}?ref={cfg.base_ref}", status_code=404, @@ -264,7 +224,6 @@ def test_file_loading_missing_file_base_ref(cfg: Settings) -> None: def test_file_loading_missing_file_head_ref(cfg: Settings, data1: bytes) -> None: with requests_mock.Mocker() as m: - mock_repo(m, cfg) mock_get_file(m, cfg, data1, cfg.base_ref) m.get( f"{cfg.api_url}/repos/{cfg.repository}/contents/{cfg.lockfile_path}?ref={cfg.ref}", @@ -285,7 +244,6 @@ def test_e2e_no_diff_existing_comment(cfg: Settings, data1: bytes) -> None: {"body": f"{MAGIC_COMMENT_IDENTIFIER}foobar", "id": 1337, "user": {"id": 41898282}}, ] mock_list_comments(m, cfg, comments) - mock_issue_comment(m, cfg, 1337, f"{MAGIC_COMMENT_IDENTIFIER}foobar") m.delete( f"{cfg.api_url}/repos/{cfg.repository}/issues/comments/1337", ) @@ -403,7 +361,7 @@ def raise_timeout(*_args: object, **_kwargs: object) -> None: msg = "timeout" raise ValueError(msg) - monkeypatch.setattr("diff_poetry_lock.github.requests.post", raise_timeout) + monkeypatch.setattr(api.requester, "graphql_query", raise_timeout) resolved_head, resolved_base = api.resolve_commit_hashes(cfg.ref, cfg.base_ref) assert resolved_head == cfg.ref @@ -462,6 +420,10 @@ def mock_list_comments(m: Mocker, s: Settings, response_json: list[dict[Any, Any f"{comments_base_url}?per_page=100", json=response_json, ) + m.get( + f"{comments_base_url}?per_page=1", + json=response_json[:1], + ) def mock_get_file(m: Mocker, s: Settings, data: bytes, ref: str, resolved_hash: str | None = None) -> None: From ce989777fa7d0f8bbdd2a4536450dfe7b63dd3e1 Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:02:11 -0500 Subject: [PATCH 7/8] Edited test checkout action to focus on latest commit Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- .github/workflows/test_and_lint.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index ce78040..6166ffb 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -49,8 +49,7 @@ jobs: if: '!inputs.skip-checkout' uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} sparse-checkout: poetry.lock persist-credentials: false - name: Diff poetry.lock From aaf47b69429ebe9489a2f3fd6b7b002702917587 Mon Sep 17 00:00:00 2001 From: banginji <7316646+banginji@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:37:56 -0500 Subject: [PATCH 8/8] Added token permissions to safe for forks github action Current setup is throwing a 403 when attempting to update an existing comment so added some token permissions to the action Signed-off-by: banginji <7316646+banginji@users.noreply.github.com> --- .github/workflows/diff-poetry-lock.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/diff-poetry-lock.yaml b/.github/workflows/diff-poetry-lock.yaml index 7c1dde5..52346c9 100644 --- a/.github/workflows/diff-poetry-lock.yaml +++ b/.github/workflows/diff-poetry-lock.yaml @@ -13,6 +13,10 @@ jobs: runs-on: ubuntu-latest name: Diff poetry.lock continue-on-error: true + permissions: + contents: read + issues: write + pull-requests: write steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1