From 165e40b17ba87e5da71d6690a13a022fdd6e3802 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Fri, 16 Jan 2026 16:28:54 -0500 Subject: [PATCH 1/4] Remove PyGitHub in favor of requests There are bugs, inconsistencies, and annoying limitations in the PyGitHub package. Switching to direct use of requests allows easily customizing the `Accept` headers to return formatted HTML rather than using a markdown component that's not quite GitHub-compatible. It also removes some messy workarounds for bugs. --- backend/pyproject.toml | 9 +- backend/src/github_pm/api.py | 385 +++---- backend/src/github_pm/logger.py | 15 + backend/tests/test_api.py | 1250 ++++++++++------------- backend/uv.lock | 183 +--- frontend/src/components/CommentCard.jsx | 8 +- frontend/src/components/IssueCard.jsx | 15 +- 7 files changed, 806 insertions(+), 1059 deletions(-) create mode 100644 backend/src/github_pm/logger.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9556f59..5610203 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "click>=8.3.1", "fastapi>=0.123.9", "pydantic-settings>=2.12.0", - "pygithub>=2.8.1", + "requests>=2.32.5", "uvicorn>=0.38.0", ] classifiers = [ @@ -134,3 +134,10 @@ skip_covered = false [tool.coverage.html] directory = "htmlcov" + +[dependency-groups] +dev = [ + "black>=25.11.0", + "flake8>=7.3.0", + "isort>=7.0.0", +] diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 2b48578..216ed17 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -1,47 +1,116 @@ from collections import defaultdict -from dataclasses import dataclass -from datetime import date +from datetime import datetime import time -from typing import Annotated, AsyncGenerator +from typing import Annotated, Any, AsyncGenerator from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query -from github import Auth, Github, Repository -from github.GithubObject import NotSet from pydantic import BaseModel, Field +import requests from github_pm.context import context +from github_pm.logger import logger api_router = APIRouter() -@dataclass -class GitHubCtx: - github: Github - repo: Repository +class Connector: + + def __init__(self, github_token: str): + """Initialize a GitHub connection. + + Args: + github_token: The GitHub Personal Access Token to use + """ + self.github_token = github_token + self.base_url = "https://api.github.com" + self.owner, self.repo = context.github_repo.split("/", maxsplit=1) + self.github = requests.session() + self.github.headers.update( + { + "Authorization": f"Bearer {self.github_token}", + "Accept": "application/vnd.github+json", + "User-Agent": "Project-Manager", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + logger.info( + "Initializing GitHub Connector service to %s/%s", + self.base_url, + context.github_repo, + ) + + def get(self, path: str, headers: dict[str, str] | None = None) -> dict: + response = self.github.get(f"{self.base_url}{path}", headers=headers) + response.raise_for_status() + self.response = response + return response.json() + + def get_paged(self, path: str, headers: dict[str, str] | None = None) -> list[dict]: + url: str | None = f"{self.base_url}{path}" + results = [] + while url: + response = self.github.get(url, headers=headers) + response.raise_for_status() + self.response = response + data = response.json() + logger.debug(f"{url}: {len(data)}") + results.extend(data) + url = None + if response.headers.get("link"): + links = response.headers.get("link") + for link in links.split(","): + if 'rel="next"' in link: + url = link.split(";")[0].strip().strip("<>") + logger.debug(f"paging to: {url}") + break + return results + + def patch( + self, path: str, data: dict[str, Any], headers: dict[str, str] | None = None + ) -> dict: + response = self.github.patch( + f"{self.base_url}{path}", json=data, headers=headers + ) + response.raise_for_status() + self.response = response + return response.json() + + def post( + self, path: str, data: dict[str, Any], headers: dict[str, str] | None = None + ) -> dict: + response = self.github.post( + f"{self.base_url}{path}", json=data, headers=headers + ) + response.raise_for_status() + self.response = response + return response.json() + def delete(self, path: str, headers: dict[str, str] | None = None) -> dict: + response = self.github.delete(f"{self.base_url}{path}", headers=headers) + response.raise_for_status() + self.response = response + return response.json() if response.content else {} -async def connection() -> AsyncGenerator[GitHubCtx, None]: + +async def connection() -> AsyncGenerator[Connector]: """FastAPI Dependency to open & close Github connections""" - gh = None - print(f"Opening GitHub connection for repo {context.github_repo}") + connector = None try: - gh = Github(auth=Auth.Token(context.github_token)) + connector = Connector(github_token=context.github_token) except Exception as e: - print(f"Error opening GitHub service: {e}") + logger.exception(f"Error opening GitHub service: {e}") raise HTTPException( status_code=400, detail=f"Can't open GitHub connection: {str(e)!r}" ) try: - # yield the repository object - repo = gh.get_repo(context.github_repo) - print(f"Repository: {repo.name}") - yield GitHubCtx(github=gh, repo=repo) + start = time.time() + yield connector + logger.debug(f"Elapsed time: {time.time() - start:.3f} seconds") except Exception as e: - print(f"Error interacting with GitHub: {type(e).__name__}->{str(e)!r}") - raise HTTPException(status_code=400, detail=f"Can't get repository: {str(e)!r}") - finally: - if gh: - gh.close() + logger.exception(f"GitHub error: {str(e)!r}") + raise HTTPException( + status_code=400, detail=f"Can't open repository: {str(e)!r}" + ) @api_router.get("/project") @@ -54,30 +123,26 @@ async def get_project(): @api_router.get("/issues/{milestone_number}") async def get_issues( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], milestone_number: Annotated[int, Path(title="Milestone")], sort: Annotated[ str | None, Query(title="Sort", description="List of labels to sort by") ] = None, ): - repo = gitctx.repo if sort: sort_by = [s.strip() for s in sort.split(",")] else: sort_by = [] sorted_issues = defaultdict(list) start = time.time() - if milestone_number == 0: - milestone = "none" - else: - milestone = repo.get_milestone(milestone_number) - print( - f"[Milestone {milestone_number} found: {milestone.title}: {time.time() - start:.3f} seconds]" - ) - issues = repo.get_issues(milestone=milestone, state="open") + milestone = "none" if milestone_number == 0 else milestone_number + issues = gitctx.get_paged( + f"/repos/{context.github_repo}/issues?milestone={milestone}&state=open", + headers={"Accept": "application/vnd.github.html+json"}, + ) for i in issues: - labels = set([label.name.lower() for label in i.labels]) - if "pull_request" not in i.raw_data: + labels = set([label["name"].lower() for label in i["labels"]]) + if "pull_request" not in i: query = """query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo, followRenames: true) { issue(number: $issue) { @@ -92,21 +157,23 @@ async def get_issues( } } """ - owner, repo = context.github_repo.split("/", maxsplit=1) try: - gql_response = gitctx.github.requester.graphql_query( - query=query, - variables={ - "owner": owner, - "repo": repo, - "issue": i.number, + response = gitctx.post( + "/graphql", + data={ + "query": query, + "variables": { + "owner": gitctx.owner, + "repo": gitctx.repo, + "issue": i["number"], + }, }, ) - data = gql_response[1]["data"] + data = response["data"] issue_node = data["repository"]["issue"] closed = issue_node["closedByPullRequestsReferences"]["nodes"] if len(closed) > 0: - i.raw_data["closed_by"] = [ + i["closed_by"] = [ { "number": linked["number"], "title": linked["title"], @@ -115,96 +182,80 @@ async def get_issues( for linked in closed ] except Exception as e: - print( - f"Error finding linked PRs for issue {i.number}: {e!r}", flush=True + logger.exception( + f"Error finding linked PRs for issue {i['number']}: {e!r}" ) continue for label in sort_by: if label in labels: - sorted_issues[label].append(i.raw_data) + sorted_issues[label].append(i) break else: - sorted_issues["other"].append(i.raw_data) + sorted_issues["other"].append(i) all_issues = [] for label in sort_by + ["other"]: all_issues.extend(sorted_issues[label]) - print( - f"[{issues.totalCount}({len(all_issues)}) issues: {time.time() - start:.3f} seconds]" + logger.debug( + f"{len(issues)}({len(all_issues)}) issues: {time.time() - start:.3f} seconds" ) return all_issues @api_router.get("/comments/{issue_number}") async def get_comments( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], ): start = time.time() - comments = gitctx.repo.get_issue(issue_number).get_comments() - simplified = [i.raw_data for i in comments] - print( - f"[{len(simplified)} issue {issue_number} comments: {time.time() - start:.3f} seconds]" + comments = gitctx.get_paged( + f"/repos/{context.github_repo}/issues/{issue_number}/comments", + headers={"Accept": "application/vnd.github.html+json"}, ) - return simplified + logger.debug( + f"{len(comments)} issue {issue_number} comments: {time.time() - start:.3f} seconds" + ) + return comments @api_router.get("/issues/{issue_number}/reactions") async def get_issue_reactions( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], ): start = time.time() - url = f"{gitctx.repo.url}/issues/{issue_number}/reactions" - print(f"URL: {url}") - reactions = gitctx.github.requester.requestJsonAndCheck("GET", url) - # NOTE: the requestor returns a tuple of headers and response. We - # only want the response. - response = reactions[1] - print( - f"[{len(response)} issue {issue_number} reactions: {time.time() - start:.3f} seconds]" + reactions = gitctx.get_paged( + f"/repos/{context.github_repo}/issues/{issue_number}/reactions", + headers={"Accept": "application/vnd.github.html+json"}, ) - return response + logger.debug( + f"{len(reactions)} issue {issue_number} reactions: {time.time() - start:.3f} seconds" + ) + return reactions @api_router.get("/comments/{comment_id}/reactions") async def get_comment_reactions( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], comment_id: Annotated[int, Path(title="Comment")], ): - start = time.time() - # FIXME: This is a hack to work around what appears to be a bug in PyGithub. - # The "/issues" prefix is not being added to the URL. This seems to be the - # distinction between "commit comments" and "issue/PR comments". - url = f"{gitctx.repo.url}/issues/comments/{comment_id}/reactions" - print(f"URL: {url}") - reactions = gitctx.github.requester.requestJsonAndCheck("GET", url) - # NOTE: the requestor returns a tuple of headers and response. We - # only want the response. - response = reactions[1] - print( - f"[{len(response)} comment {comment_id} reactions: {time.time() - start:.3f} seconds]" + reactions = gitctx.get_paged( + f"/repos/{context.github_repo}/issues/comments/{comment_id}/reactions", + headers={"Accept": "application/vnd.github.html+json"}, ) - return response + return reactions -"""Milestone Management""" +# """Milestone Management""" @api_router.get("/milestones") -async def get_milestones(gitctx: Annotated[GitHubCtx, Depends(connection)]): - repo = gitctx.repo - milestones = repo.get_milestones() - response = [ - { - "title": m.title, - "number": m.number, - "description": m.description, - "due_on": m.due_on, - } - for m in milestones - ] - response.append( +async def get_milestones(gitctx: Annotated[Connector, Depends(connection)]): + milestones = gitctx.get_paged( + f"/repos/{context.github_repo}/milestones", + headers={"Accept": "application/vnd.github.html+json"}, + ) + milestones.append( { "title": "none", "number": 0, @@ -212,156 +263,140 @@ async def get_milestones(gitctx: Annotated[GitHubCtx, Depends(connection)]): "due_on": None, } ) - return response + return milestones class CreateMilestone(BaseModel): title: str = Field(title="Milestone Title") - description: str = Field(default=NotSet, title="Milestone Description") - due_on: date = Field(default=NotSet, title="Milestone Due Date") + description: str | None = Field(default=None, title="Milestone Description") + due_on: datetime | None = Field(default=None, title="Milestone Due Date") @api_router.post("/milestones") async def create_milestone( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], milestone: Annotated[CreateMilestone, Body(title="Milestone")], ): - start = time.time() - print(f"Creating milestone: {milestone!r}", flush=True) - m = gitctx.repo.create_milestone( - title=milestone.title, - state="open", - description=milestone.description, - due_on=milestone.due_on, + logger.info( + f"Creating milestone: {milestone!r} ({milestone.due_on.isoformat() if milestone.due_on else None!r})" ) - print(f"[{milestone.title} milestone created: {time.time() - start:.3f} seconds]") - return m.raw_data + data = { + "title": milestone.title, + "state": "open", + "description": milestone.description, + } + if milestone.due_on: + data["due_on"] = milestone.due_on.isoformat() + m = gitctx.post(f"/repos/{context.github_repo}/milestones", data=data) + return m @api_router.delete("/milestones/{milestone_number}") async def delete_milestone( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], milestone_number: Annotated[int, Path(title="Milestone")], ): - start = time.time() - try: - milestone = gitctx.repo.get_milestone(milestone_number) - except Exception as e: - print(f"Milestone not found: {milestone_number!r}", flush=True) - raise HTTPException( - status_code=400, - detail=f"Milestone {milestone_number!r} not found: {str(e)!r}", - ) - milestone.delete() - print(f"[{milestone_number} milestone deleted: {time.time() - start:.3f} seconds]") + gitctx.delete(f"/repos/{context.github_repo}/milestones/{milestone_number}") return {"message": f"{milestone_number} milestone deleted"} @api_router.post("/issues/{issue_number}/milestone/{milestone_number}") async def add_milestone_to_issue( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], milestone_number: Annotated[int, Path(title="Milestone")], ): - start = time.time() - issue = gitctx.repo.get_issue(issue_number) - milestone = gitctx.repo.get_milestone(milestone_number) - issue.edit(milestone=milestone) - print( - f"[{milestone_number} milestone added to issue {issue_number}: {time.time() - start:.3f} seconds]" + issue = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"milestone": milestone_number}, ) - return {"message": f"{milestone_number} milestone added to issue {issue_number}"} + return issue @api_router.delete("/issues/{issue_number}/milestone/{milestone_number}") async def remove_milestone_from_issue( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], milestone_number: Annotated[int, Path(title="Milestone")], ): - start = time.time() - issue = gitctx.repo.get_issue(issue_number) - issue.edit(milestone=None) - print( - f"[{milestone_number} milestone removed from issue {issue_number}: {time.time() - start:.3f} seconds]" + issue = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"milestone": milestone_number}, ) - return { - "message": f"{milestone_number} milestone removed from issue {issue_number}" - } + return issue -"""Label Management""" +# """Label Management""" @api_router.get("/labels") -async def get_labels(gitctx: Annotated[GitHubCtx, Depends(connection)]): - start = time.time() - labels = [label.raw_data for label in gitctx.repo.get_labels()] - print(f"[{len(labels)} labels: {time.time() - start:.3f} seconds]") +async def get_labels(gitctx: Annotated[Connector, Depends(connection)]): + labels = gitctx.get_paged( + f"/repos/{context.github_repo}/labels", + headers={"Accept": "application/vnd.github.html+json"}, + ) return labels class CreateLabel(BaseModel): name: str = Field(title="Label Name") color: str = Field(title="Label Color") - description: str = Field(default=NotSet, title="Label Description") + description: str | None = Field(default=None, title="Label Description") @api_router.post("/labels") async def create_label( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], label: Annotated[CreateLabel, Body(title="Label")], ): - start = time.time() - label = gitctx.repo.create_label( - name=label.name, color=label.color, description=label.description + response = gitctx.post( + f"/repos/{context.github_repo}/labels", + data={ + "name": label.name, + "color": label.color, + "description": label.description, + }, ) - print(f"[{label.name} label created: {time.time() - start:.3f} seconds]") - return label.raw_data + return response @api_router.delete("/labels/{label_name}") async def delete_label( - gitctx: Annotated[GitHubCtx, Depends(connection)], label_name: str + gitctx: Annotated[Connector, Depends(connection)], label_name: str ): - start = time.time() - try: - label = gitctx.repo.get_label(label_name) - except Exception as e: - print(f"Label not found: {label_name!r}", flush=True) - raise HTTPException( - status_code=400, detail=f"Label {label_name!r} not found: {str(e)!r}" - ) - label.delete() - print(f"[{label_name} label deleted: {time.time() - start:.3f} seconds]") + gitctx.delete(f"/repos/{context.github_repo}/labels/{label_name}") return {"message": f"{label_name} label deleted"} @api_router.post("/issues/{issue_number}/labels/{label_name}") async def add_label_to_issue( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], label_name: Annotated[str, Path(title="Label")], ): - start = time.time() - issue = gitctx.repo.get_issue(issue_number) - issue.add_to_labels(label_name) - print( - f"[{label_name} label added to issue {issue_number}: {time.time() - start:.3f} seconds]" - ) - return {"message": f"{label_name} label added to issue {issue_number}"} + issue = gitctx.get(f"/repos/{context.github_repo}/issues/{issue_number}") + labels = set([label["name"] for label in issue["labels"]]) + if label_name not in labels: + labels.add(label_name) + issue = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"labels": list(labels)}, + ) + return issue @api_router.delete("/issues/{issue_number}/labels/{label_name}") async def remove_label_from_issue( - gitctx: Annotated[GitHubCtx, Depends(connection)], + gitctx: Annotated[Connector, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], label_name: Annotated[str, Path(title="Label")], ): - start = time.time() - issue = gitctx.repo.get_issue(issue_number) - issue.remove_from_labels(label_name) - print( - f"[{label_name} label removed from issue {issue_number}: {time.time() - start:.3f} seconds]" - ) - return + issue = gitctx.get(f"/repos/{context.github_repo}/issues/{issue_number}") + labels = set([label["name"] for label in issue["labels"]]) + if label_name in labels: + labels.remove(label_name) + issue = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"labels": list(labels)}, + ) + return issue diff --git a/backend/src/github_pm/logger.py b/backend/src/github_pm/logger.py new file mode 100644 index 0000000..f5dfed7 --- /dev/null +++ b/backend/src/github_pm/logger.py @@ -0,0 +1,15 @@ +# Set up a Logger at class level rather than at each instance creation +import logging +import os + +formatter = logging.Formatter( + "%(asctime)s %(process)d:%(thread)d %(levelname)s %(module)s:%(lineno)d %(message)s" +) +handler = logging.StreamHandler() +handler.setFormatter(formatter) +logger = logging.getLogger("Planner") +logger.addHandler(handler) +level: int | str = logging.DEBUG +if os.getenv("GITHUB_PM_LOG_LEVEL"): + level = os.getenv("GITHUB_PM_LOG_LEVEL") +logger.setLevel(level) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index adc76b5..72c8cbf 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -3,7 +3,6 @@ ai-generated: Cursor """ -from datetime import date from unittest.mock import Mock, patch from fastapi import HTTPException @@ -15,6 +14,7 @@ add_milestone_to_issue, api_router, connection, + Connector, create_label, create_milestone, CreateLabel, @@ -28,7 +28,6 @@ get_labels, get_milestones, get_project, - GitHubCtx, remove_label_from_issue, remove_milestone_from_issue, ) @@ -42,13 +41,9 @@ class TestConnection: async def test_connection_success(self): """Test successful GitHub connection.""" # Arrange - mock_repo = Mock() - mock_repo.name = "test-repo" - mock_github = Mock() - mock_github.get_repo.return_value = mock_repo - + mock_session = Mock() with ( - patch("github_pm.api.Github", return_value=mock_github), + patch("github_pm.api.requests.session", return_value=mock_session), patch("github_pm.api.context") as mock_context, ): mock_context.github_repo = "test/repo" @@ -59,24 +54,26 @@ async def test_connection_success(self): gitctx = await async_gen.__anext__() # Assert - assert isinstance(gitctx, GitHubCtx) - assert gitctx.repo == mock_repo - assert gitctx.github == mock_github - mock_github.get_repo.assert_called_once_with("test/repo") + assert isinstance(gitctx, Connector) + assert gitctx.github_token == "test_token" + assert gitctx.owner == "test" + assert gitctx.repo == "repo" + assert gitctx.github == mock_session # Clean up - trigger the finally block try: await async_gen.__anext__() except StopAsyncIteration: pass - mock_github.close.assert_called_once() @pytest.mark.asyncio async def test_connection_github_init_error(self): """Test connection when GitHub initialization fails.""" # Arrange with ( - patch("github_pm.api.Github", side_effect=Exception("Auth failed")), + patch( + "github_pm.api.requests.session", side_effect=Exception("Auth failed") + ), patch("github_pm.api.context") as mock_context, ): mock_context.github_repo = "test/repo" @@ -90,35 +87,38 @@ async def test_connection_github_init_error(self): @pytest.mark.asyncio async def test_connection_get_repo_error(self): - """Test connection when get_repo fails.""" + """Test connection when repository access fails.""" # Arrange - mock_github = Mock() - mock_github.get_repo.side_effect = Exception("Repo not found") - + mock_session = Mock() + # Simulate an error during initialization (e.g., invalid token) with ( - patch("github_pm.api.Github", return_value=mock_github), + patch("github_pm.api.requests.session", return_value=mock_session), patch("github_pm.api.context") as mock_context, ): mock_context.github_repo = "test/repo" mock_context.github_token = "test_token" + # Simulate error when trying to access repo + mock_session.get.side_effect = Exception("Repo not found") - # Act & Assert + # Act async_gen = connection() - with pytest.raises(HTTPException) as exc_info: + gitctx = await async_gen.__anext__() + # The error would occur when actually using the connector, not during init + assert isinstance(gitctx, Connector) + + # Clean up + try: await async_gen.__anext__() - assert exc_info.value.status_code == 400 + except StopAsyncIteration: + pass @pytest.mark.asyncio async def test_connection_closes_on_exit(self): - """Test that GitHub connection is closed after use.""" + """Test that GitHub connection is properly initialized.""" # Arrange - mock_repo = Mock() - mock_repo.name = "test-repo" - mock_github = Mock() - mock_github.get_repo.return_value = mock_repo - + mock_session = Mock() with ( - patch("github_pm.api.Github", return_value=mock_github), + patch("github_pm.api.requests.session", return_value=mock_session), patch("github_pm.api.context") as mock_context, ): mock_context.github_repo = "test/repo" @@ -127,8 +127,8 @@ async def test_connection_closes_on_exit(self): # Act async_gen = connection() gitctx = await async_gen.__anext__() - assert isinstance(gitctx, GitHubCtx) - assert gitctx.repo == mock_repo + assert isinstance(gitctx, Connector) + assert gitctx.github == mock_session # Trigger cleanup by consuming the generator try: @@ -136,8 +136,8 @@ async def test_connection_closes_on_exit(self): except StopAsyncIteration: pass - # Assert - mock_github.close.assert_called_once() + # Assert - session should have headers set + mock_session.headers.update.assert_called() class TestGetProject: @@ -168,52 +168,35 @@ class TestGetIssues: async def test_get_issues_with_milestone(self): """Test getting issues for a specific milestone.""" # Arrange - mock_milestone = Mock() - mock_milestone.title = "Test Milestone" - mock_label1 = Mock() - mock_label1.name = "bug" - mock_label2 = Mock() - mock_label2.name = "feature" - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Issue 1", "pull_request": {}} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label1] - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Issue 2"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label2] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1, mock_issue2]) - - mock_repo = Mock() - mock_repo.get_milestone.return_value = mock_milestone - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ { + "id": 1, + "number": 1, + "title": "Issue 1", + "pull_request": {}, + "labels": [{"name": "bug"}], + }, + { + "id": 2, + "number": 2, + "title": "Issue 2", + "labels": [{"name": "feature"}], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -225,46 +208,28 @@ def __iter__(self): assert len(result) == 2 assert result[0]["id"] == 1 assert result[1]["id"] == 2 - mock_repo.get_milestone.assert_called_once_with(1) - mock_repo.get_issues.assert_called_once_with( - milestone=mock_milestone, state="open" - ) + mock_gitctx.get_paged.assert_called_once() # Issue 1 has pull_request, so no GraphQL call # Issue 2 doesn't have pull_request, so GraphQL should be called - assert mock_requester.graphql_query.call_count == 1 + assert mock_gitctx.post.call_count == 1 @pytest.mark.asyncio async def test_get_issues_with_linked_prs(self): """Test getting issues with linked PRs from GraphQL.""" # Arrange - mock_milestone = Mock() - mock_milestone.title = "Test Milestone" - mock_label = Mock() - mock_label.name = "bug" - mock_issue = Mock() - mock_issue.raw_data = {"id": 1, "title": "Issue 1"} - mock_issue.number = 1 - mock_issue.labels = [mock_label] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue]) - - mock_repo = Mock() - mock_repo.get_milestone.return_value = mock_milestone - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ { + "id": 1, + "number": 1, + "title": "Issue 1", + "labels": [{"name": "bug"}], + } + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": { @@ -285,13 +250,10 @@ def __iter__(self): } } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -311,47 +273,34 @@ def __iter__(self): == "https://github.com/test/repo/pull/123" ) assert result[0]["closed_by"][1]["number"] == 456 - mock_requester.graphql_query.assert_called_once() + mock_gitctx.post.assert_called_once() @pytest.mark.asyncio async def test_get_issues_with_no_milestone(self): """Test getting issues with milestone_number=0 (no milestone).""" # Arrange - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Issue 1"} - mock_issue1.number = 1 - mock_issue1.labels = [] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1]) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ { + "id": 1, + "number": 1, + "title": "Issue 1", + "labels": [], + } + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -362,67 +311,48 @@ def __iter__(self): # Assert assert len(result) == 1 assert result[0]["id"] == 1 - mock_repo.get_issues.assert_called_once_with(milestone="none", state="open") - mock_repo.get_milestone.assert_not_called() + mock_gitctx.get_paged.assert_called_once() # GraphQL should be called for issue without pull_request - assert mock_requester.graphql_query.call_count == 1 + assert mock_gitctx.post.call_count == 1 @pytest.mark.asyncio async def test_get_issues_with_sort_single_label(self): """Test getting issues sorted by a single label.""" # Arrange - mock_label_bug = Mock() - mock_label_bug.name = "bug" - mock_label_feature = Mock() - mock_label_feature.name = "feature" - mock_label_other = Mock() - mock_label_other.name = "documentation" - - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Bug Issue"} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label_bug] - - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Feature Issue"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label_feature] - - mock_issue3 = Mock() - mock_issue3.raw_data = {"id": 3, "title": "Other Issue"} - mock_issue3.number = 3 - mock_issue3.labels = [mock_label_other] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1, mock_issue2, mock_issue3]) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ + { + "id": 1, + "number": 1, + "title": "Bug Issue", + "labels": [{"name": "bug"}], + }, + { + "id": 2, + "number": 2, + "title": "Feature Issue", + "labels": [{"name": "feature"}], + }, { + "id": 3, + "number": 3, + "title": "Other Issue", + "labels": [{"name": "documentation"}], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -443,65 +373,46 @@ def __iter__(self): async def test_get_issues_with_sort_multiple_labels(self): """Test getting issues sorted by multiple labels in order.""" # Arrange - mock_label_bug = Mock() - mock_label_bug.name = "bug" - mock_label_feature = Mock() - mock_label_feature.name = "feature" - mock_label_enhancement = Mock() - mock_label_enhancement.name = "enhancement" - - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Bug Issue"} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label_bug] - - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Feature Issue"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label_feature] - - mock_issue3 = Mock() - mock_issue3.raw_data = {"id": 3, "title": "Enhancement Issue"} - mock_issue3.number = 3 - mock_issue3.labels = [mock_label_enhancement] - - mock_issue4 = Mock() - mock_issue4.raw_data = {"id": 4, "title": "Unlabeled Issue"} - mock_issue4.number = 4 - mock_issue4.labels = [] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues( - [mock_issue1, mock_issue2, mock_issue3, mock_issue4] - ) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ + { + "id": 1, + "number": 1, + "title": "Bug Issue", + "labels": [{"name": "bug"}], + }, + { + "id": 2, + "number": 2, + "title": "Feature Issue", + "labels": [{"name": "feature"}], + }, { + "id": 3, + "number": 3, + "title": "Enhancement Issue", + "labels": [{"name": "enhancement"}], + }, + { + "id": 4, + "number": 4, + "title": "Unlabeled Issue", + "labels": [], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -523,51 +434,34 @@ def __iter__(self): async def test_get_issues_with_sort_case_insensitive(self): """Test that label matching is case-insensitive.""" # Arrange - mock_label_bug = Mock() - mock_label_bug.name = "BUG" # Uppercase label - mock_label_feature = Mock() - mock_label_feature.name = "Feature" # Mixed case label - - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Bug Issue"} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label_bug] - - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Feature Issue"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label_feature] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1, mock_issue2]) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ { + "id": 1, + "number": 1, + "title": "Bug Issue", + "labels": [{"name": "BUG"}], # Uppercase label + }, + { + "id": 2, + "number": 2, + "title": "Feature Issue", + "labels": [{"name": "Feature"}], # Mixed case label + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -587,51 +481,34 @@ def __iter__(self): async def test_get_issues_with_sort_whitespace_stripped(self): """Test that whitespace in sort parameter is stripped.""" # Arrange - mock_label_bug = Mock() - mock_label_bug.name = "bug" - mock_label_feature = Mock() - mock_label_feature.name = "feature" - - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Bug Issue"} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label_bug] - - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Feature Issue"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label_feature] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1, mock_issue2]) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ { + "id": 1, + "number": 1, + "title": "Bug Issue", + "labels": [{"name": "bug"}], + }, + { + "id": 2, + "number": 2, + "title": "Feature Issue", + "labels": [{"name": "feature"}], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -651,52 +528,37 @@ def __iter__(self): async def test_get_issues_with_sort_first_match_wins(self): """Test that issues matching multiple labels go to the first matching label.""" # Arrange - mock_label_bug = Mock() - mock_label_bug.name = "bug" - mock_label_feature = Mock() - mock_label_feature.name = "feature" - - # Issue with both labels - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Bug/Feature Issue"} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label_bug, mock_label_feature] - - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Feature Only Issue"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label_feature] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1, mock_issue2]) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ + { + "id": 1, + "number": 1, + "title": "Bug/Feature Issue", + "labels": [ + {"name": "bug"}, + {"name": "feature"}, + ], # Issue with both labels + }, { + "id": 2, + "number": 2, + "title": "Feature Only Issue", + "labels": [{"name": "feature"}], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -716,51 +578,34 @@ def __iter__(self): async def test_get_issues_with_sort_all_other(self): """Test that issues without sort parameter all go to 'other'.""" # Arrange - mock_label_bug = Mock() - mock_label_bug.name = "bug" - mock_label_feature = Mock() - mock_label_feature.name = "feature" - - mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Bug Issue"} - mock_issue1.number = 1 - mock_issue1.labels = [mock_label_bug] - - mock_issue2 = Mock() - mock_issue2.raw_data = {"id": 2, "title": "Feature Issue"} - mock_issue2.number = 2 - mock_issue2.labels = [mock_label_feature] - - # Create an iterable mock with totalCount - class IterableIssues: - def __init__(self, issues): - self.issues = issues - self.totalCount = len(issues) - - def __iter__(self): - return iter(self.issues) - - mock_issues_obj = IterableIssues([mock_issue1, mock_issue2]) - - mock_repo = Mock() - mock_repo.get_issues.return_value = mock_issues_obj - - mock_requester = Mock() - mock_requester.graphql_query.return_value = ( - None, + mock_issues = [ + { + "id": 1, + "number": 1, + "title": "Bug Issue", + "labels": [{"name": "bug"}], + }, { + "id": 2, + "number": 2, + "title": "Feature Issue", + "labels": [{"name": "feature"}], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock( + return_value={ "data": { "repository": { "issue": {"closedByPullRequestsReferences": {"nodes": []}} } } - }, + } ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" with patch("github_pm.api.context") as mock_context: mock_context.github_repo = "test/repo" @@ -782,21 +627,13 @@ class TestGetComments: async def test_get_comments(self): """Test getting comments for an issue.""" # Arrange - mock_comment1 = Mock() - mock_comment1.raw_data = {"id": 1, "body": "Comment 1"} - mock_comment2 = Mock() - mock_comment2.raw_data = {"id": 2, "body": "Comment 2"} - mock_comments = Mock() - mock_comments.__iter__ = Mock(return_value=iter([mock_comment1, mock_comment2])) - - mock_issue = Mock() - mock_issue.get_comments.return_value = mock_comments - - mock_repo = Mock() - mock_repo.get_issue.return_value = mock_issue + mock_comments = [ + {"id": 1, "body": "Comment 1", "body_html": "

Comment 1

"}, + {"id": 2, "body": "Comment 2", "body_html": "

Comment 2

"}, + ] - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_comments) # Act result = await get_comments(mock_gitctx, issue_number=123) @@ -805,8 +642,7 @@ async def test_get_comments(self): assert len(result) == 2 assert result[0]["id"] == 1 assert result[1]["id"] == 2 - mock_repo.get_issue.assert_called_once_with(123) - mock_issue.get_comments.assert_called_once() + mock_gitctx.get_paged.assert_called_once() class TestGetMilestones: @@ -816,28 +652,23 @@ class TestGetMilestones: async def test_get_milestones(self): """Test getting all milestones.""" # Arrange - mock_milestone1 = Mock() - mock_milestone1.title = "Milestone 1" - mock_milestone1.number = 1 - mock_milestone1.description = "Description 1" - mock_milestone1.due_on = date(2024, 12, 31) - - mock_milestone2 = Mock() - mock_milestone2.title = "Milestone 2" - mock_milestone2.number = 2 - mock_milestone2.description = "Description 2" - mock_milestone2.due_on = None - - mock_milestones = Mock() - mock_milestones.__iter__ = Mock( - return_value=iter([mock_milestone1, mock_milestone2]) - ) - - mock_repo = Mock() - mock_repo.get_milestones.return_value = mock_milestones + mock_milestones = [ + { + "title": "Milestone 1", + "number": 1, + "description": "Description 1", + "due_on": "2024-12-31T00:00:00Z", + }, + { + "title": "Milestone 2", + "number": 2, + "description": "Description 2", + "due_on": None, + }, + ] - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_milestones) # Act result = await get_milestones(mock_gitctx) @@ -851,6 +682,7 @@ async def test_get_milestones(self): assert result[2]["title"] == "none" assert result[2]["number"] == 0 assert result[2]["due_on"] is None + mock_gitctx.get_paged.assert_called_once() class TestCreateMilestone: @@ -860,58 +692,60 @@ class TestCreateMilestone: async def test_create_milestone_success(self): """Test successfully creating a milestone.""" # Arrange - mock_milestone = Mock() - mock_milestone.raw_data = {"id": 1, "title": "New Milestone"} + from datetime import datetime - mock_repo = Mock() - mock_repo.create_milestone.return_value = mock_milestone + mock_milestone_response = {"id": 1, "title": "New Milestone"} milestone_data = CreateMilestone( title="New Milestone", description="Test description", - due_on=date(2024, 12, 31), + due_on=datetime(2024, 12, 31), ) - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_milestone_response) - # Act - result = await create_milestone(mock_gitctx, milestone_data) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Assert - assert result == mock_milestone.raw_data - mock_repo.create_milestone.assert_called_once_with( - title="New Milestone", - state="open", - description="Test description", - due_on=date(2024, 12, 31), - ) + # Act + result = await create_milestone(mock_gitctx, milestone_data) + + # Assert + assert result == mock_milestone_response + mock_gitctx.post.assert_called_once() + call_args = mock_gitctx.post.call_args + assert call_args[0][0] == "/repos/test/repo/milestones" + assert call_args[1]["data"]["title"] == "New Milestone" + assert call_args[1]["data"]["state"] == "open" + assert call_args[1]["data"]["description"] == "Test description" @pytest.mark.asyncio async def test_create_milestone_with_defaults(self): """Test creating a milestone with default values.""" # Arrange - from github.GithubObject import NotSet - - mock_milestone = Mock() - mock_milestone.raw_data = {"id": 1, "title": "New Milestone"} - - mock_repo = Mock() - mock_repo.create_milestone.return_value = mock_milestone + mock_milestone_response = {"id": 1, "title": "New Milestone"} milestone_data = CreateMilestone(title="New Milestone") - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_milestone_response) - # Act - result = await create_milestone(mock_gitctx, milestone_data) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Assert - assert result == mock_milestone.raw_data - mock_repo.create_milestone.assert_called_once_with( - title="New Milestone", state="open", description=NotSet, due_on=NotSet - ) + # Act + result = await create_milestone(mock_gitctx, milestone_data) + + # Assert + assert result == mock_milestone_response + mock_gitctx.post.assert_called_once() + call_args = mock_gitctx.post.call_args + assert call_args[0][0] == "/repos/test/repo/milestones" + assert call_args[1]["data"]["title"] == "New Milestone" + assert call_args[1]["data"]["state"] == "open" + assert call_args[1]["data"]["description"] is None + assert "due_on" not in call_args[1]["data"] class TestDeleteMilestone: @@ -921,36 +755,32 @@ class TestDeleteMilestone: async def test_delete_milestone_success(self): """Test successfully deleting a milestone.""" # Arrange - mock_milestone = Mock() - mock_milestone.delete = Mock() + mock_gitctx = Mock(spec=Connector) + mock_gitctx.delete = Mock(return_value={}) - mock_repo = Mock() - mock_repo.get_milestone.return_value = mock_milestone - - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await delete_milestone(mock_gitctx, milestone_number=1) + # Act + result = await delete_milestone(mock_gitctx, milestone_number=1) - # Assert - assert result == {"message": "1 milestone deleted"} - mock_repo.get_milestone.assert_called_once_with(1) - mock_milestone.delete.assert_called_once() + # Assert + assert result == {"message": "1 milestone deleted"} + mock_gitctx.delete.assert_called_once_with("/repos/test/repo/milestones/1") @pytest.mark.asyncio async def test_delete_milestone_not_found(self): """Test deleting a milestone that doesn't exist.""" # Arrange - mock_repo = Mock() - mock_repo.get_milestone.side_effect = Exception("Milestone not found") + mock_gitctx = Mock(spec=Connector) + mock_gitctx.delete = Mock(side_effect=Exception("Milestone not found")) - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act & Assert - with pytest.raises(Exception): - await delete_milestone(mock_gitctx, milestone_number=999) + # Act & Assert + with pytest.raises(Exception): + await delete_milestone(mock_gitctx, milestone_number=999) class TestAddMilestoneToIssue: @@ -960,28 +790,24 @@ class TestAddMilestoneToIssue: async def test_add_milestone_to_issue(self): """Test adding a milestone to an issue.""" # Arrange - mock_issue = Mock() - mock_issue.edit = Mock() + mock_issue_response = {"id": 123, "milestone": {"number": 1}} - mock_milestone = Mock() + mock_gitctx = Mock(spec=Connector) + mock_gitctx.patch = Mock(return_value=mock_issue_response) - mock_repo = Mock() - mock_repo.get_issue.return_value = mock_issue - mock_repo.get_milestone.return_value = mock_milestone - - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await add_milestone_to_issue( - mock_gitctx, issue_number=123, milestone_number=1 - ) + # Act + result = await add_milestone_to_issue( + mock_gitctx, issue_number=123, milestone_number=1 + ) - # Assert - assert result == {"message": "1 milestone added to issue 123"} - mock_repo.get_issue.assert_called_once_with(123) - mock_repo.get_milestone.assert_called_once_with(1) - mock_issue.edit.assert_called_once_with(milestone=mock_milestone) + # Assert + assert result == mock_issue_response + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/123", data={"milestone": 1} + ) class TestRemoveMilestoneFromIssue: @@ -991,24 +817,24 @@ class TestRemoveMilestoneFromIssue: async def test_remove_milestone_from_issue(self): """Test removing a milestone from an issue.""" # Arrange - mock_issue = Mock() - mock_issue.edit = Mock() + mock_issue_response = {"id": 123, "milestone": None} - mock_repo = Mock() - mock_repo.get_issue.return_value = mock_issue + mock_gitctx = Mock(spec=Connector) + mock_gitctx.patch = Mock(return_value=mock_issue_response) - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await remove_milestone_from_issue( - mock_gitctx, issue_number=123, milestone_number=1 - ) + # Act + result = await remove_milestone_from_issue( + mock_gitctx, issue_number=123, milestone_number=1 + ) - # Assert - assert result == {"message": "1 milestone removed from issue 123"} - mock_repo.get_issue.assert_called_once_with(123) - mock_issue.edit.assert_called_once_with(milestone=None) + # Assert + assert result == mock_issue_response + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/123", data={"milestone": 1} + ) class TestGetLabels: @@ -1018,19 +844,13 @@ class TestGetLabels: async def test_get_labels(self): """Test getting all labels.""" # Arrange - mock_label1 = Mock() - mock_label1.raw_data = {"id": 1, "name": "bug", "color": "red"} - mock_label2 = Mock() - mock_label2.raw_data = {"id": 2, "name": "feature", "color": "blue"} + mock_labels = [ + {"id": 1, "name": "bug", "color": "red"}, + {"id": 2, "name": "feature", "color": "blue"}, + ] - mock_labels = Mock() - mock_labels.__iter__ = Mock(return_value=iter([mock_label1, mock_label2])) - - mock_repo = Mock() - mock_repo.get_labels.return_value = mock_labels - - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_labels) # Act result = await get_labels(mock_gitctx) @@ -1039,7 +859,7 @@ async def test_get_labels(self): assert len(result) == 2 assert result[0]["name"] == "bug" assert result[1]["name"] == "feature" - mock_repo.get_labels.assert_called_once() + mock_gitctx.get_paged.assert_called_once() class TestCreateLabel: @@ -1049,55 +869,55 @@ class TestCreateLabel: async def test_create_label_success(self): """Test successfully creating a label.""" # Arrange - mock_label = Mock() - mock_label.name = "new-label" - mock_label.raw_data = {"id": 1, "name": "new-label", "color": "green"} - - mock_repo = Mock() - mock_repo.create_label.return_value = mock_label + mock_label_response = {"id": 1, "name": "new-label", "color": "green"} label_data = CreateLabel( name="new-label", color="green", description="Test label" ) - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_label_response) - # Act - result = await create_label(mock_gitctx, label_data) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Assert - assert result == mock_label.raw_data - mock_repo.create_label.assert_called_once_with( - name="new-label", color="green", description="Test label" - ) + # Act + result = await create_label(mock_gitctx, label_data) + + # Assert + assert result == mock_label_response + mock_gitctx.post.assert_called_once() + call_args = mock_gitctx.post.call_args + assert call_args[0][0] == "/repos/test/repo/labels" + assert call_args[1]["data"]["name"] == "new-label" + assert call_args[1]["data"]["color"] == "green" + assert call_args[1]["data"]["description"] == "Test label" @pytest.mark.asyncio async def test_create_label_with_defaults(self): """Test creating a label with default description.""" # Arrange - from github.GithubObject import NotSet - - mock_label = Mock() - mock_label.name = "new-label" - mock_label.raw_data = {"id": 1, "name": "new-label", "color": "green"} - - mock_repo = Mock() - mock_repo.create_label.return_value = mock_label + mock_label_response = {"id": 1, "name": "new-label", "color": "green"} label_data = CreateLabel(name="new-label", color="green") - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_label_response) - # Act - result = await create_label(mock_gitctx, label_data) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Assert - assert result == mock_label.raw_data - mock_repo.create_label.assert_called_once_with( - name="new-label", color="green", description=NotSet - ) + # Act + result = await create_label(mock_gitctx, label_data) + + # Assert + assert result == mock_label_response + mock_gitctx.post.assert_called_once() + call_args = mock_gitctx.post.call_args + assert call_args[0][0] == "/repos/test/repo/labels" + assert call_args[1]["data"]["name"] == "new-label" + assert call_args[1]["data"]["color"] == "green" + assert call_args[1]["data"]["description"] is None class TestDeleteLabel: @@ -1107,36 +927,32 @@ class TestDeleteLabel: async def test_delete_label_success(self): """Test successfully deleting a label.""" # Arrange - mock_label = Mock() - mock_label.delete = Mock() + mock_gitctx = Mock(spec=Connector) + mock_gitctx.delete = Mock(return_value={}) - mock_repo = Mock() - mock_repo.get_label.return_value = mock_label - - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await delete_label(mock_gitctx, label_name="bug") + # Act + result = await delete_label(mock_gitctx, label_name="bug") - # Assert - assert result == {"message": "bug label deleted"} - mock_repo.get_label.assert_called_once_with("bug") - mock_label.delete.assert_called_once() + # Assert + assert result == {"message": "bug label deleted"} + mock_gitctx.delete.assert_called_once_with("/repos/test/repo/labels/bug") @pytest.mark.asyncio async def test_delete_label_not_found(self): """Test deleting a label that doesn't exist.""" # Arrange - mock_repo = Mock() - mock_repo.get_label.side_effect = Exception("Label not found") + mock_gitctx = Mock(spec=Connector) + mock_gitctx.delete = Mock(side_effect=Exception("Label not found")) - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act & Assert - with pytest.raises(Exception): - await delete_label(mock_gitctx, label_name="nonexistent") + # Act & Assert + with pytest.raises(Exception): + await delete_label(mock_gitctx, label_name="nonexistent") class TestAddLabelToIssue: @@ -1146,24 +962,61 @@ class TestAddLabelToIssue: async def test_add_label_to_issue(self): """Test adding a label to an issue.""" # Arrange - mock_issue = Mock() - mock_issue.add_to_labels = Mock() + mock_issue_get = { + "id": 123, + "labels": [{"name": "existing"}], + } + mock_issue_patch = { + "id": 123, + "labels": [{"name": "existing"}, {"name": "bug"}], + } + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock(return_value=mock_issue_get) + mock_gitctx.patch = Mock(return_value=mock_issue_patch) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + + # Act + result = await add_label_to_issue( + mock_gitctx, issue_number=123, label_name="bug" + ) + + # Assert + assert result == mock_issue_patch + mock_gitctx.get.assert_called_once_with("/repos/test/repo/issues/123") + mock_gitctx.patch.assert_called_once() + # Check that patch was called with correct path and that labels contain both + call_args = mock_gitctx.patch.call_args + assert call_args[0][0] == "/repos/test/repo/issues/123" + assert set(call_args[1]["data"]["labels"]) == {"existing", "bug"} + + @pytest.mark.asyncio + async def test_add_label_to_issue_already_exists(self): + """Test adding a label that already exists on an issue.""" + # Arrange + mock_issue_get = { + "id": 123, + "labels": [{"name": "bug"}], + } - mock_repo = Mock() - mock_repo.get_issue.return_value = mock_issue + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock(return_value=mock_issue_get) + mock_gitctx.patch = Mock() - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await add_label_to_issue( - mock_gitctx, issue_number=123, label_name="bug" - ) + # Act + result = await add_label_to_issue( + mock_gitctx, issue_number=123, label_name="bug" + ) - # Assert - assert result == {"message": "bug label added to issue 123"} - mock_repo.get_issue.assert_called_once_with(123) - mock_issue.add_to_labels.assert_called_once_with("bug") + # Assert + assert result == mock_issue_get + mock_gitctx.get.assert_called_once_with("/repos/test/repo/issues/123") + mock_gitctx.patch.assert_not_called() class TestRemoveLabelFromIssue: @@ -1173,24 +1026,59 @@ class TestRemoveLabelFromIssue: async def test_remove_label_from_issue(self): """Test removing a label from an issue.""" # Arrange - mock_issue = Mock() - mock_issue.remove_from_labels = Mock() + mock_issue_get = { + "id": 123, + "labels": [{"name": "bug"}, {"name": "feature"}], + } + mock_issue_patch = { + "id": 123, + "labels": [{"name": "feature"}], + } + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock(return_value=mock_issue_get) + mock_gitctx.patch = Mock(return_value=mock_issue_patch) - mock_repo = Mock() - mock_repo.get_issue.return_value = mock_issue + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - mock_github = Mock() - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act + result = await remove_label_from_issue( + mock_gitctx, issue_number=123, label_name="bug" + ) - # Act - result = await remove_label_from_issue( - mock_gitctx, issue_number=123, label_name="bug" - ) + # Assert + assert result == mock_issue_patch + mock_gitctx.get.assert_called_once_with("/repos/test/repo/issues/123") + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/123", data={"labels": ["feature"]} + ) - # Assert - assert result is None - mock_repo.get_issue.assert_called_once_with(123) - mock_issue.remove_from_labels.assert_called_once_with("bug") + @pytest.mark.asyncio + async def test_remove_label_from_issue_not_present(self): + """Test removing a label that doesn't exist on an issue.""" + # Arrange + mock_issue_get = { + "id": 123, + "labels": [{"name": "feature"}], + } + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock(return_value=mock_issue_get) + mock_gitctx.patch = Mock() + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + + # Act + result = await remove_label_from_issue( + mock_gitctx, issue_number=123, label_name="bug" + ) + + # Assert + assert result == mock_issue_get + mock_gitctx.get.assert_called_once_with("/repos/test/repo/issues/123") + mock_gitctx.patch.assert_not_called() class TestGetIssueReactions: @@ -1200,62 +1088,44 @@ class TestGetIssueReactions: async def test_get_issue_reactions(self): """Test getting reactions for an issue.""" # Arrange - mock_repo = Mock() - mock_repo.url = "https://api.github.com/repos/test/repo" - - mock_requester = Mock() - mock_requester.requestJsonAndCheck.return_value = ( - {"Content-Type": "application/json"}, - [ - {"id": 1, "content": "+1", "user": {"login": "user1"}}, - {"id": 2, "content": "heart", "user": {"login": "user2"}}, - ], - ) + mock_reactions = [ + {"id": 1, "content": "+1", "user": {"login": "user1"}}, + {"id": 2, "content": "heart", "user": {"login": "user2"}}, + ] - mock_github = Mock() - mock_github.requester = mock_requester + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_reactions) - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await get_issue_reactions(mock_gitctx, issue_number=123) + # Act + result = await get_issue_reactions(mock_gitctx, issue_number=123) - # Assert - assert len(result) == 2 - assert result[0]["id"] == 1 - assert result[0]["content"] == "+1" - assert result[1]["id"] == 2 - assert result[1]["content"] == "heart" - mock_requester.requestJsonAndCheck.assert_called_once_with( - "GET", "https://api.github.com/repos/test/repo/issues/123/reactions" - ) + # Assert + assert len(result) == 2 + assert result[0]["id"] == 1 + assert result[0]["content"] == "+1" + assert result[1]["id"] == 2 + assert result[1]["content"] == "heart" + mock_gitctx.get_paged.assert_called_once() @pytest.mark.asyncio async def test_get_issue_reactions_empty(self): """Test getting reactions for an issue with no reactions.""" # Arrange - mock_repo = Mock() - mock_repo.url = "https://api.github.com/repos/test/repo" + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=[]) - mock_requester = Mock() - mock_requester.requestJsonAndCheck.return_value = ( - {"Content-Type": "application/json"}, - [], - ) - - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await get_issue_reactions(mock_gitctx, issue_number=456) + # Act + result = await get_issue_reactions(mock_gitctx, issue_number=456) - # Assert - assert result == [] - mock_requester.requestJsonAndCheck.assert_called_once_with( - "GET", "https://api.github.com/repos/test/repo/issues/456/reactions" - ) + # Assert + assert result == [] + mock_gitctx.get_paged.assert_called_once() class TestGetCommentReactions: @@ -1265,67 +1135,47 @@ class TestGetCommentReactions: async def test_get_comment_reactions(self): """Test getting reactions for a comment.""" # Arrange - mock_repo = Mock() - mock_repo.url = "https://api.github.com/repos/test/repo" - - mock_requester = Mock() - mock_requester.requestJsonAndCheck.return_value = ( - {"Content-Type": "application/json"}, - [ - {"id": 1, "content": "laugh", "user": {"login": "user1"}}, - {"id": 2, "content": "hooray", "user": {"login": "user2"}}, - {"id": 3, "content": "confused", "user": {"login": "user3"}}, - ], - ) + mock_reactions = [ + {"id": 1, "content": "laugh", "user": {"login": "user1"}}, + {"id": 2, "content": "hooray", "user": {"login": "user2"}}, + {"id": 3, "content": "confused", "user": {"login": "user3"}}, + ] - mock_github = Mock() - mock_github.requester = mock_requester + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_reactions) - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await get_comment_reactions(mock_gitctx, comment_id=789) + # Act + result = await get_comment_reactions(mock_gitctx, comment_id=789) - # Assert - assert len(result) == 3 - assert result[0]["id"] == 1 - assert result[0]["content"] == "laugh" - assert result[1]["id"] == 2 - assert result[1]["content"] == "hooray" - assert result[2]["id"] == 3 - assert result[2]["content"] == "confused" - mock_requester.requestJsonAndCheck.assert_called_once_with( - "GET", - "https://api.github.com/repos/test/repo/issues/comments/789/reactions", - ) + # Assert + assert len(result) == 3 + assert result[0]["id"] == 1 + assert result[0]["content"] == "laugh" + assert result[1]["id"] == 2 + assert result[1]["content"] == "hooray" + assert result[2]["id"] == 3 + assert result[2]["content"] == "confused" + mock_gitctx.get_paged.assert_called_once() @pytest.mark.asyncio async def test_get_comment_reactions_empty(self): """Test getting reactions for a comment with no reactions.""" # Arrange - mock_repo = Mock() - mock_repo.url = "https://api.github.com/repos/test/repo" - - mock_requester = Mock() - mock_requester.requestJsonAndCheck.return_value = ( - {"Content-Type": "application/json"}, - [], - ) + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=[]) - mock_github = Mock() - mock_github.requester = mock_requester - - mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" - # Act - result = await get_comment_reactions(mock_gitctx, comment_id=999) + # Act + result = await get_comment_reactions(mock_gitctx, comment_id=999) - # Assert - assert result == [] - mock_requester.requestJsonAndCheck.assert_called_once_with( - "GET", - "https://api.github.com/repos/test/repo/issues/comments/999/reactions", - ) + # Assert + assert result == [] + mock_gitctx.get_paged.assert_called_once() class TestAPIRouterIntegration: diff --git a/backend/uv.lock b/backend/uv.lock index 9b64493..0e12874 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -71,39 +71,6 @@ wheels = [ { 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]] -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 = "chardet" version = "5.2.0" @@ -194,62 +161,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, ] -[[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 = "distlib" version = "0.4.0" @@ -305,7 +216,7 @@ dependencies = [ { name = "click" }, { name = "fastapi" }, { name = "pydantic-settings" }, - { name = "pygithub" }, + { name = "requests" }, { name = "uvicorn" }, ] @@ -321,6 +232,13 @@ dev = [ { name = "tox" }, ] +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=24.4.2" }, @@ -330,15 +248,22 @@ requires-dist = [ { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=6.0.1" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, - { name = "pygithub", specifier = ">=2.8.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "requests", specifier = ">=2.32.5" }, { name = "tox", marker = "extra == 'dev'", specifier = ">=4.23.2" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=25.11.0" }, + { name = "flake8", specifier = ">=7.3.0" }, + { name = "isort", specifier = ">=7.0.0" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -466,15 +391,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] -[[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.5" @@ -552,22 +468,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] -[[package]] -name = "pygithub" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyjwt", extra = ["crypto"] }, - { name = "pynacl" }, - { name = "requests" }, - { name = "typing-extensions" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/74/e560bdeffea72ecb26cff27f0fad548bbff5ecc51d6a155311ea7f9e4c4c/pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9", size = 2246994, upload-time = "2025-09-02T17:41:54.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/ba/7049ce39f653f6140aac4beb53a5aaf08b4407b6a3019aae394c1c5244ff/pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0", size = 432709, upload-time = "2025-09-02T17:41:52.947Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -577,57 +477,6 @@ 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 = "pynacl" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/46/aeca065d227e2265125aea590c9c47fbf5786128c9400ee0eb7c88931f06/pynacl-1.6.1.tar.gz", hash = "sha256:8d361dac0309f2b6ad33b349a56cd163c98430d409fa503b10b70b3ad66eaa1d", size = 3506616, upload-time = "2025-11-10T16:02:13.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/d6/4b2dca33ed512de8f54e5c6074aa06eaeb225bfbcd9b16f33a414389d6bd/pynacl-1.6.1-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:7d7c09749450c385301a3c20dca967a525152ae4608c0a096fe8464bfc3df93d", size = 389109, upload-time = "2025-11-10T16:01:28.79Z" }, - { url = "https://files.pythonhosted.org/packages/3c/30/e8dbb8ff4fa2559bbbb2187ba0d0d7faf728d17cb8396ecf4a898b22d3da/pynacl-1.6.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc734c1696ffd49b40f7c1779c89ba908157c57345cf626be2e0719488a076d3", size = 808254, upload-time = "2025-11-10T16:01:37.839Z" }, - { url = "https://files.pythonhosted.org/packages/44/f9/f5449c652f31da00249638dbab065ad4969c635119094b79b17c3a4da2ab/pynacl-1.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3cd787ec1f5c155dc8ecf39b1333cfef41415dc96d392f1ce288b4fe970df489", size = 1407365, upload-time = "2025-11-10T16:01:40.454Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2f/9aa5605f473b712065c0a193ebf4ad4725d7a245533f0cd7e5dcdbc78f35/pynacl-1.6.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b35d93ab2df03ecb3aa506be0d3c73609a51449ae0855c2e89c7ed44abde40b", size = 843842, upload-time = "2025-11-10T16:01:30.524Z" }, - { url = "https://files.pythonhosted.org/packages/32/8d/748f0f6956e207453da8f5f21a70885fbbb2e060d5c9d78e0a4a06781451/pynacl-1.6.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dece79aecbb8f4640a1adbb81e4aa3bfb0e98e99834884a80eb3f33c7c30e708", size = 1445559, upload-time = "2025-11-10T16:01:33.663Z" }, - { url = "https://files.pythonhosted.org/packages/78/d0/2387f0dcb0e9816f38373999e48db4728ed724d31accdd4e737473319d35/pynacl-1.6.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c2228054f04bf32d558fb89bb99f163a8197d5a9bf4efa13069a7fa8d4b93fc3", size = 825791, upload-time = "2025-11-10T16:01:34.823Z" }, - { url = "https://files.pythonhosted.org/packages/18/3d/ef6fb7eb072aaf15f280bc66f26ab97e7fc9efa50fb1927683013ef47473/pynacl-1.6.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:2b12f1b97346f177affcdfdc78875ff42637cb40dcf79484a97dae3448083a78", size = 1410843, upload-time = "2025-11-10T16:01:36.401Z" }, - { url = "https://files.pythonhosted.org/packages/e3/fb/23824a017526850ee7d8a1cc4cd1e3e5082800522c10832edbbca8619537/pynacl-1.6.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e735c3a1bdfde3834503baf1a6d74d4a143920281cb724ba29fb84c9f49b9c48", size = 801140, upload-time = "2025-11-10T16:01:42.013Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d1/ebc6b182cb98603a35635b727d62f094bc201bf610f97a3bb6357fe688d2/pynacl-1.6.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3384a454adf5d716a9fadcb5eb2e3e72cd49302d1374a60edc531c9957a9b014", size = 1371966, upload-time = "2025-11-10T16:01:43.297Z" }, - { url = "https://files.pythonhosted.org/packages/64/f4/c9d7b6f02924b1f31db546c7bd2a83a2421c6b4a8e6a2e53425c9f2802e0/pynacl-1.6.1-cp314-cp314t-win32.whl", hash = "sha256:d8615ee34d01c8e0ab3f302dcdd7b32e2bcf698ba5f4809e7cc407c8cdea7717", size = 230482, upload-time = "2025-11-10T16:01:47.688Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2c/942477957fba22da7bf99131850e5ebdff66623418ab48964e78a7a8293e/pynacl-1.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5f5b35c1a266f8a9ad22525049280a600b19edd1f785bccd01ae838437dcf935", size = 243232, upload-time = "2025-11-10T16:01:45.208Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/bdbc0d04a53b96a765ab03aa2cf9a76ad8653d70bf1665459b9a0dedaa1c/pynacl-1.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d984c91fe3494793b2a1fb1e91429539c6c28e9ec8209d26d25041ec599ccf63", size = 187907, upload-time = "2025-11-10T16:01:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/49/41/3cfb3b4f3519f6ff62bf71bf1722547644bcfb1b05b8fdbdc300249ba113/pynacl-1.6.1-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:a6f9fd6d6639b1e81115c7f8ff16b8dedba1e8098d2756275d63d208b0e32021", size = 387591, upload-time = "2025-11-10T16:01:49.1Z" }, - { url = "https://files.pythonhosted.org/packages/18/21/b8a6563637799f617a3960f659513eccb3fcc655d5fc2be6e9dc6416826f/pynacl-1.6.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e49a3f3d0da9f79c1bec2aa013261ab9fa651c7da045d376bd306cf7c1792993", size = 798866, upload-time = "2025-11-10T16:01:55.688Z" }, - { url = "https://files.pythonhosted.org/packages/e8/6c/dc38033bc3ea461e05ae8f15a81e0e67ab9a01861d352ae971c99de23e7c/pynacl-1.6.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7713f8977b5d25f54a811ec9efa2738ac592e846dd6e8a4d3f7578346a841078", size = 1398001, upload-time = "2025-11-10T16:01:57.101Z" }, - { url = "https://files.pythonhosted.org/packages/9f/05/3ec0796a9917100a62c5073b20c4bce7bf0fea49e99b7906d1699cc7b61b/pynacl-1.6.1-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3becafc1ee2e5ea7f9abc642f56b82dcf5be69b961e782a96ea52b55d8a9fc", size = 834024, upload-time = "2025-11-10T16:01:50.228Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b7/ae9982be0f344f58d9c64a1c25d1f0125c79201634efe3c87305ac7cb3e3/pynacl-1.6.1-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ce50d19f1566c391fedc8dc2f2f5be265ae214112ebe55315e41d1f36a7f0a9", size = 1436766, upload-time = "2025-11-10T16:01:51.886Z" }, - { url = "https://files.pythonhosted.org/packages/b4/51/b2ccbf89cf3025a02e044dd68a365cad593ebf70f532299f2c047d2b7714/pynacl-1.6.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:543f869140f67d42b9b8d47f922552d7a967e6c116aad028c9bfc5f3f3b3a7b7", size = 817275, upload-time = "2025-11-10T16:01:53.351Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6c/dd9ee8214edf63ac563b08a9b30f98d116942b621d39a751ac3256694536/pynacl-1.6.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a2bb472458c7ca959aeeff8401b8efef329b0fc44a89d3775cffe8fad3398ad8", size = 1401891, upload-time = "2025-11-10T16:01:54.587Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c1/97d3e1c83772d78ee1db3053fd674bc6c524afbace2bfe8d419fd55d7ed1/pynacl-1.6.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3206fa98737fdc66d59b8782cecc3d37d30aeec4593d1c8c145825a345bba0f0", size = 772291, upload-time = "2025-11-10T16:01:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/4d/ca/691ff2fe12f3bb3e43e8e8df4b806f6384593d427f635104d337b8e00291/pynacl-1.6.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:53543b4f3d8acb344f75fd4d49f75e6572fce139f4bfb4815a9282296ff9f4c0", size = 1370839, upload-time = "2025-11-10T16:01:59.252Z" }, - { url = "https://files.pythonhosted.org/packages/30/27/06fe5389d30391fce006442246062cc35773c84fbcad0209fbbf5e173734/pynacl-1.6.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319de653ef84c4f04e045eb250e6101d23132372b0a61a7acf91bac0fda8e58c", size = 791371, upload-time = "2025-11-10T16:02:01.075Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7a/e2bde8c9d39074a5aa046c7d7953401608d1f16f71e237f4bef3fb9d7e49/pynacl-1.6.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:262a8de6bba4aee8a66f5edf62c214b06647461c9b6b641f8cd0cb1e3b3196fe", size = 1363031, upload-time = "2025-11-10T16:02:02.656Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b6/63fd77264dae1087770a1bb414bc604470f58fbc21d83822fc9c76248076/pynacl-1.6.1-cp38-abi3-win32.whl", hash = "sha256:9fd1a4eb03caf8a2fe27b515a998d26923adb9ddb68db78e35ca2875a3830dde", size = 226585, upload-time = "2025-11-10T16:02:07.116Z" }, - { url = "https://files.pythonhosted.org/packages/12/c8/b419180f3fdb72ab4d45e1d88580761c267c7ca6eda9a20dcbcba254efe6/pynacl-1.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:a569a4069a7855f963940040f35e87d8bc084cb2d6347428d5ad20550a0a1a21", size = 238923, upload-time = "2025-11-10T16:02:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/35/76/c34426d532e4dce7ff36e4d92cb20f4cbbd94b619964b93d24e8f5b5510f/pynacl-1.6.1-cp38-abi3-win_arm64.whl", hash = "sha256:5953e8b8cfadb10889a6e7bd0f53041a745d1b3d30111386a1bb37af171e6daf", size = 183970, upload-time = "2025-11-10T16:02:05.786Z" }, -] - [[package]] name = "pyproject-api" version = "1.10.0" diff --git a/frontend/src/components/CommentCard.jsx b/frontend/src/components/CommentCard.jsx index 8fbbf0e..4f80a0b 100644 --- a/frontend/src/components/CommentCard.jsx +++ b/frontend/src/components/CommentCard.jsx @@ -1,7 +1,5 @@ // ai-generated: Cursor import React, { useState, useEffect } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { Spinner, Alert } from '@patternfly/react-core'; import { getDaysSince, formatDate } from '../utils/dateUtils'; import { fetchCommentReactions } from '../services/api'; @@ -84,11 +82,7 @@ const CommentCard = ({ comment }) => { -
- - {comment.body} - -
+
{comment.reactions?.total_count > 0 && (
{reactionsLoading && ( diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index 4cbd36f..975d1a4 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -18,8 +18,6 @@ import { FormGroup, } from '@patternfly/react-core'; import { CodeBranchIcon } from '@patternfly/react-icons'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { getDaysSince, formatDate } from '../utils/dateUtils'; import { fetchComments, @@ -1048,21 +1046,20 @@ const IssueCard = ({ issue, onMilestoneChange }) => { paddingBottom: '0.75rem', }} > - {issue.body && ( + {issue.body_html && ( setIsDescriptionExpanded(!isDescriptionExpanded)} isExpanded={isDescriptionExpanded} > -
- - {issue.body} - -
+
)} {issue.comments > 0 && isDescriptionExpanded && ( -
+
setIsCommentsExpanded(!isCommentsExpanded)} From e20be25dfe1a751621a882a058cb1d83605fdf7e Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Fri, 16 Jan 2026 16:41:23 -0500 Subject: [PATCH 2/4] Fix UI unit tests --- frontend/src/components/CommentCard.test.jsx | 3 ++- frontend/src/components/IssueCard.test.jsx | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/CommentCard.test.jsx b/frontend/src/components/CommentCard.test.jsx index a6e8f82..83223bc 100644 --- a/frontend/src/components/CommentCard.test.jsx +++ b/frontend/src/components/CommentCard.test.jsx @@ -7,6 +7,7 @@ describe('CommentCard', () => { const mockComment = { id: 3148506196, body: 'Hi @sjmonson, thanks for opening this!', + body_html: '

Hi @sjmonson, thanks for opening this!

', user: { login: 'MaxMarriottClarke', avatar_url: 'https://avatars.githubusercontent.com/u/108399722?v=4', @@ -14,7 +15,7 @@ describe('CommentCard', () => { created_at: '2025-08-03T15:47:13Z', }; - it('renders comment body as markdown', () => { + it('renders comment body as HTML', () => { render(); expect(screen.getByText(/Hi @sjmonson/i)).toBeInTheDocument(); }); diff --git a/frontend/src/components/IssueCard.test.jsx b/frontend/src/components/IssueCard.test.jsx index 938a88a..92448c4 100644 --- a/frontend/src/components/IssueCard.test.jsx +++ b/frontend/src/components/IssueCard.test.jsx @@ -13,6 +13,8 @@ describe('IssueCard', () => { number: 459, title: 'Add support for OpenAI Responses API', body: '**Is your feature request related to a problem?**\n\nYes, it is.', + body_html: + '

Is your feature request related to a problem?

Yes, it is.

', html_url: 'https://github.com/vllm-project/guidellm/issues/459', user: { login: 'tosokin', @@ -79,13 +81,14 @@ describe('IssueCard', () => { expect(screen.queryByText(/comment/)).not.toBeInTheDocument(); }); - it('expands and shows markdown body when toggle is clicked', async () => { + it('expands and shows HTML body when toggle is clicked', async () => { const user = userEvent.setup(); render(); const toggleButton = screen.getByText('Show Description'); await user.click(toggleButton); await waitFor(() => { + // The HTML content should be rendered, check for the text content expect( screen.getByText(/Is your feature request related to a problem/i) ).toBeInTheDocument(); @@ -124,7 +127,7 @@ describe('IssueCard', () => { }); it('handles missing body gracefully', () => { - const issueWithoutBody = { ...mockIssue, body: null }; + const issueWithoutBody = { ...mockIssue, body: null, body_html: null }; render(); expect(screen.getByText('#459')).toBeInTheDocument(); }); @@ -249,6 +252,7 @@ describe('IssueCard', () => { { id: 1, body: 'This is a comment', + body_html: '

This is a comment

', user: { login: 'testuser', avatar_url: 'https://avatar.url', From d78ecf8841d788e01241439a09b1c2cbcb31ed89 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Fri, 16 Jan 2026 16:44:40 -0500 Subject: [PATCH 3/4] Cursor review found a bug... Milestone remove wasn't removing. Oops! --- backend/src/github_pm/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 216ed17..9eab3c8 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -321,7 +321,7 @@ async def remove_milestone_from_issue( ): issue = gitctx.patch( f"/repos/{context.github_repo}/issues/{issue_number}", - data={"milestone": milestone_number}, + data={"milestone": None}, ) return issue From a63a56dfc8b9c7ed39c19f1060aed979f8afa06a Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Fri, 16 Jan 2026 16:47:22 -0500 Subject: [PATCH 4/4] Fix unit test --- backend/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 72c8cbf..ea90879 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -833,7 +833,7 @@ async def test_remove_milestone_from_issue(self): # Assert assert result == mock_issue_response mock_gitctx.patch.assert_called_once_with( - "/repos/test/repo/issues/123", data={"milestone": 1} + "/repos/test/repo/issues/123", data={"milestone": None} )