Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,22 @@ This friction complicates the responsible acceptance of pull requests that chang

## Example

<img width="916" alt="image" src="https://user-images.githubusercontent.com/1723176/224580589-bd5e7a5f-e39f-40d3-91a2-b4bd02284100.png">
```markdown
### Detected 6 changes to dependencies in Poetry lockfile

From base f4e6ca0f4d67d9bb3f8ab43a89ceca2d0d2be7a1 to target a86b84f85d0bb2bf2fca6d6e8c58f2ce6f9e393c:

Added **pydantic** (1.10.6)
Added **requests-mock** (1.10.0)
Added **six** (1.16.0)
Added **tomli** (2.0.1)
Added **typing-extensions** (4.5.0)
Updated **urllib3** (1.26.14 -> 1.26.15)

*(5 added, 0 removed, 1 updated, 4 not changed)*

<small>Generated by diff-poetry-lock 1.0.1</small>
```

## Usage

Expand Down
6 changes: 6 additions & 0 deletions diff_poetry_lock/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from importlib.metadata import PackageNotFoundError, version

try:
__version__ = version("diff-poetry-lock")
except PackageNotFoundError:
__version__ = "dev"
48 changes: 38 additions & 10 deletions diff_poetry_lock/github.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import base64

import requests
from loguru import logger
from pydantic import BaseModel, Field, parse_obj_as
from requests import Response

from diff_poetry_lock.settings import PrLookupConfigurable, Settings

Expand Down Expand Up @@ -30,6 +31,7 @@ class GithubApi:
def __init__(self, settings: Settings) -> None:
self.s = settings
self.session = requests.session()
self._ref_hash_cache: dict[str, str] = {}
if isinstance(self.s, PrLookupConfigurable):
self.s.set_pr_lookup_service(self)

Expand All @@ -45,7 +47,7 @@ def post_comment(self, comment: str) -> None:
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={"Authorization": f"token {self.s.token}", "Accept": "application/vnd.github+json"},
headers=self.api_headers(),
json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{comment}"},
timeout=10,
)
Expand All @@ -56,7 +58,7 @@ 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={"Authorization": f"token {self.s.token}", "Accept": "application/vnd.github+json"},
headers=self.api_headers(),
json={"body": f"{MAGIC_COMMENT_IDENTIFIER}{comment}"},
timeout=10,
)
Expand All @@ -74,7 +76,7 @@ def list_comments(self) -> list[GithubComment]:
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={"Authorization": f"token {self.s.token}", "Accept": "application/vnd.github+json"},
headers=self.api_headers(),
timeout=10,
)
r.raise_for_status()
Expand All @@ -84,28 +86,47 @@ def list_comments(self) -> list[GithubComment]:
logger.debug("Found %d comments", len(all_comments))
return [c for c in all_comments if c.is_diff_comment()]

def get_file(self, ref: str) -> Response:
def get_file(self, ref: str) -> bytes:
logger.debug("Fetching {} from ref {}", self.s.lockfile_path, ref)

r = self.session.get(
f"{self.s.api_url}/repos/{self.s.repository}/contents/{self.s.lockfile_path}",
params={"ref": ref},
headers={"Authorization": f"token {self.s.token}", "Accept": "application/vnd.github.raw"},
headers=self.api_headers(),
timeout=10,
stream=True,
)
logger.debug("Response status: {}", r.status_code)

if r.status_code == 404:
raise FileNotFoundError(self.s.lockfile_path) from RepoFileRetrievalError(self.s.repository, ref)
r.raise_for_status()
return r
file_obj = r.json()

resolved_hash = str(file_obj.get("sha", "")).strip()
if resolved_hash:
self._ref_hash_cache[ref] = resolved_hash
logger.debug("Cached commit hash for ref {} from contents sha", ref)

encoded_content = file_obj.get("content", "")
Comment on lines +103 to +110
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: This is 100% fine for now but we're inching closer to wanting/needing some kind of abstraction, like a Github API library. Octocat was the go-to in the Ruby ecosystem. I don't know what's the analogue in the Python world.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Created a new issue to track and implement it in #47

if not isinstance(encoded_content, str):
msg = "Invalid content returned from GitHub contents API"
raise TypeError(msg)

return base64.b64decode(encoded_content)

def resolve_commit_hash(self, ref: str) -> str:
if cached_hash := self._ref_hash_cache.get(ref):
logger.debug("Using cached commit hash for ref {}", ref)
return cached_hash

logger.warning("No cached commit hash for ref {}, falling back to ref", ref)
return ref

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={"Authorization": f"token {self.s.token}", "Accept": "application/vnd.github+json"},
headers=self.api_headers(),
)
logger.debug("Response status: {}", r.status_code)
r.raise_for_status()
Expand All @@ -122,7 +143,7 @@ def find_pr_for_branch(self, branch_ref: str) -> str:
r = self.session.get(
f"{self.s.api_url}/repos/{self.s.repository}/pulls",
params={"head": head, "state": "open"},
headers={"Authorization": f"token {self.s.token}", "Accept": "application/vnd.github+json"},
headers=self.api_headers(),
timeout=10,
)
logger.debug("Response status: {}", r.status_code)
Expand All @@ -137,6 +158,13 @@ def find_pr_for_branch(self, branch_ref: str) -> str:
logger.debug("No open PR found for branch {}", branch)
return ""

def api_headers(self) -> dict[str, str]:
return self.request_headers(self.s.token)

@staticmethod
def request_headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}

def upsert_comment(self, existing_comment: GithubComment | None, comment: str | None) -> None:
if existing_comment is None and comment is None:
return
Expand Down
34 changes: 27 additions & 7 deletions diff_poetry_lock/run_poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from poetry.core.packages.package import Package
from poetry.packages import Locker

from diff_poetry_lock import __version__
from diff_poetry_lock.github import GithubApi
from diff_poetry_lock.logging_utils import configure_logging
from diff_poetry_lock.settings import Settings, determine_and_load_settings
Expand Down Expand Up @@ -79,7 +80,11 @@ def post_comment(api: GithubApi, comment: str | None) -> None:
api.upsert_comment(existing_comment, comment)


def format_comment(packages: list[PackageSummary]) -> str | None:
def format_comment(
packages: list[PackageSummary],
base_commit_hash: str | None = None,
target_commit_hash: str | None = None,
) -> str | None:
added = sorted([p for p in packages if p.added()], key=attrgetter("name"))
removed = sorted([p for p in packages if p.removed()], key=attrgetter("name"))
updated = sorted([p for p in packages if p.updated()], key=attrgetter("name"))
Expand All @@ -88,20 +93,25 @@ def format_comment(packages: list[PackageSummary]) -> str | None:
if len(added + removed + updated) == 0:
return None

comment = f"### Detected {len(added + removed + updated)} changes to dependencies in Poetry lockfile\n\n"
comment += "\n".join(p.summary_line() for p in added + removed + updated)
change_count = len(added + removed + updated)
comment = f"### Detected {change_count} changes to dependencies in Poetry lockfile\n\n"
if base_commit_hash and target_commit_hash:
comment += f"From base {base_commit_hash} to target {target_commit_hash}:\n\n"
summary_lines = [p.summary_line() for p in added + removed + updated]
comment += "\n".join(summary_lines)
comment += (
f"\n\n*({len(added)} added, {len(removed)} removed, {len(updated)} updated, {len(not_changed)} not changed)*"
)
if __version__:
comment += f"\n\n<small>Generated by diff-poetry-lock {__version__}</small>\n\n"

return comment


def load_lockfile(api: GithubApi, ref: str) -> list[Package]:
r = api.get_file(ref)
file_contents = api.get_file(ref)
with tempfile.NamedTemporaryFile(mode="wb", delete=True) as f:
for chunk in r.iter_content(chunk_size=1024):
f.write(chunk)
f.write(file_contents)
f.flush()

return load_packages(Path(f.name))
Expand All @@ -128,7 +138,17 @@ def do_diff(settings: Settings) -> None:

logger.debug("Computing diff...")
packages = diff(base_packages, head_packages)
summary = format_comment(packages)

if not any(package.changed() for package in packages):
summary = None
else:
base_commit_hash = api.resolve_commit_hash(settings.ref)
target_commit_hash = api.resolve_commit_hash(settings.base_ref)
summary = format_comment(
packages,
base_commit_hash=base_commit_hash,
target_commit_hash=target_commit_hash,
)

if summary:
logger.debug("Generated summary with {} characters", len(summary))
Expand Down
Loading