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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 73 additions & 12 deletions backend/src/github_pm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
_GITHUB_504_MAX_ATTEMPTS = 5
_GITHUB_504_BACKOFF_SEC = 1.5

# full+json returns raw markdown ``body`` and rendered ``body_html``.
# html+json alone omits ``body``, which breaks edit-in-place flows.
# Assisted-by: Cursor
_GITHUB_BODY_ACCEPT = {"Accept": "application/vnd.github.full+json"}


class Connector:
def __init__(self, github_token: str, *, github_repo: str | None = None):
Expand Down Expand Up @@ -255,7 +260,7 @@ async def get_issues(
milestone = "none" if milestone_number == 0 else milestone_number
raw_items = gitctx.get_paged(
f"/repos/{context.github_repo}/issues?milestone={milestone}&state=open",
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
issues: list[dict] = []
pull_requests: list[dict] = []
Expand Down Expand Up @@ -314,7 +319,7 @@ async def get_issue(
):
issue = gitctx.get(
f"/repos/{context.github_repo}/issues/{issue_number}",
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
if "pull_request" not in issue:
query = """query($owner: String!, $repo: String!, $issue: Int!) {
Expand Down Expand Up @@ -370,7 +375,7 @@ async def get_comments(
start = time.time()
comments = gitctx.get_paged(
f"/repos/{context.github_repo}/issues/{issue_number}/comments",
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
logger.debug(
f"{len(comments)} issue {issue_number} comments: {time.time() - start:.3f} seconds"
Expand Down Expand Up @@ -400,12 +405,68 @@ async def create_comment(
created = gitctx.post(
f"/repos/{context.github_repo}/issues/{issue_number}/comments",
data={"body": comment.body},
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
logger.info("Created comment on issue #%s", issue_number)
return created


class UpdateComment(BaseModel):
"""Body for updating an issue comment.

Generated-by: Cursor
"""

body: str = Field(title="Comment Body", min_length=1)


@api_router.patch("/comments/{comment_id}/body")
async def update_comment(
gitctx: Annotated[Connector, Depends(connection)],
comment_id: Annotated[int, Path(title="Comment ID")],
comment: Annotated[UpdateComment, Body(title="Comment")],
):
"""Update an issue comment's markdown body.

Generated-by: Cursor
"""
updated = gitctx.patch(
f"/repos/{context.github_repo}/issues/comments/{comment_id}",
data={"body": comment.body},
headers=_GITHUB_BODY_ACCEPT,
)
logger.info("Updated comment %s", comment_id)
return updated


class UpdateIssueBody(BaseModel):
"""Body for updating an issue description.

Generated-by: Cursor
"""

body: str = Field(title="Issue Body", default="")


@api_router.patch("/issues/{issue_number}/body")
async def update_issue_body(
gitctx: Annotated[Connector, Depends(connection)],
issue_number: Annotated[int, Path(title="Issue")],
payload: Annotated[UpdateIssueBody, Body(title="Issue Body")],
):
"""Update an issue's markdown description.

Generated-by: Cursor
"""
updated = gitctx.patch(
f"/repos/{context.github_repo}/issues/{issue_number}",
data={"body": payload.body},
headers=_GITHUB_BODY_ACCEPT,
)
logger.info("Updated body for issue #%s", issue_number)
return updated


class CloseWithComment(BaseModel):
"""Body for closing an issue with an optional comment.

Expand All @@ -428,12 +489,12 @@ async def close_issue_with_comment(
created_comment = gitctx.post(
f"/repos/{context.github_repo}/issues/{issue_number}/comments",
data={"body": comment.body},
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
closed_issue = gitctx.patch(
f"/repos/{context.github_repo}/issues/{issue_number}",
data={"state": "closed"},
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
logger.info("Closed issue #%s with comment", issue_number)
return {"comment": created_comment, "issue": closed_issue}
Expand Down Expand Up @@ -508,7 +569,7 @@ async def create_issue(
created = gitctx.post(
f"/repos/{context.github_repo}/issues",
data=data,
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
logger.info("Created issue #%s: %s", created.get("number"), issue.title)

Expand Down Expand Up @@ -538,7 +599,7 @@ async def get_issue_reactions(
start = time.time()
reactions = gitctx.get_paged(
f"/repos/{context.github_repo}/issues/{issue_number}/reactions",
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
logger.debug(
f"{len(reactions)} issue {issue_number} reactions: {time.time() - start:.3f} seconds"
Expand All @@ -553,7 +614,7 @@ async def get_comment_reactions(
):
reactions = gitctx.get_paged(
f"/repos/{context.github_repo}/issues/comments/{comment_id}/reactions",
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
return reactions

Expand All @@ -565,7 +626,7 @@ async def get_comment_reactions(
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"},
headers=_GITHUB_BODY_ACCEPT,
)
versions = []
others = []
Expand Down Expand Up @@ -827,7 +888,7 @@ async def adopt_parent_milestone(
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"},
headers=_GITHUB_BODY_ACCEPT,
)
return labels

Expand Down Expand Up @@ -904,7 +965,7 @@ async def get_assignees(gitctx: Annotated[Connector, Depends(connection)]):
"""Get all allowed assignees for the repository"""
assignees = gitctx.get_paged(
f"/repos/{context.github_repo}/assignees",
headers={"Accept": "application/vnd.github.html+json"},
headers=_GITHUB_BODY_ACCEPT,
)
return sorted(assignees, key=lambda x: x["login"])

Expand Down
85 changes: 83 additions & 2 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
RenderMarkdown,
set_issue_parent,
SetIssueParent,
update_comment,
update_issue_body,
UpdateComment,
UpdateIssueBody,
)
from github_pm.app import app

Expand Down Expand Up @@ -1658,7 +1662,84 @@ async def test_create_comment_success(self):
mock_gitctx.post.assert_called_once_with(
"/repos/test/repo/issues/42/comments",
data={"body": "Hello"},
headers={"Accept": "application/vnd.github.html+json"},
headers={"Accept": "application/vnd.github.full+json"},
)


class TestUpdateComment:
"""Test the update_comment endpoint.

Generated-by: Cursor
"""

@pytest.mark.asyncio
async def test_update_comment_success(self):
mock_comment = {
"id": 99,
"body": "Updated",
"body_html": "<p>Updated</p>",
}
mock_gitctx = Mock(spec=Connector)
mock_gitctx.patch = Mock(return_value=mock_comment)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"
result = await update_comment(
mock_gitctx, 99, UpdateComment(body="Updated")
)

assert result == mock_comment
mock_gitctx.patch.assert_called_once_with(
"/repos/test/repo/issues/comments/99",
data={"body": "Updated"},
headers={"Accept": "application/vnd.github.full+json"},
)


class TestUpdateIssueBody:
"""Test the update_issue_body endpoint.

Generated-by: Cursor
"""

@pytest.mark.asyncio
async def test_update_issue_body_success(self):
mock_issue = {
"number": 42,
"body": "New description",
"body_html": "<p>New description</p>",
}
mock_gitctx = Mock(spec=Connector)
mock_gitctx.patch = Mock(return_value=mock_issue)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"
result = await update_issue_body(
mock_gitctx, 42, UpdateIssueBody(body="New description")
)

assert result == mock_issue
mock_gitctx.patch.assert_called_once_with(
"/repos/test/repo/issues/42",
data={"body": "New description"},
headers={"Accept": "application/vnd.github.full+json"},
)

@pytest.mark.asyncio
async def test_update_issue_body_allows_empty(self):
mock_issue = {"number": 42, "body": "", "body_html": ""}
mock_gitctx = Mock(spec=Connector)
mock_gitctx.patch = Mock(return_value=mock_issue)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"
result = await update_issue_body(mock_gitctx, 42, UpdateIssueBody(body=""))

assert result == mock_issue
mock_gitctx.patch.assert_called_once_with(
"/repos/test/repo/issues/42",
data={"body": ""},
headers={"Accept": "application/vnd.github.full+json"},
)


Expand Down Expand Up @@ -1687,7 +1768,7 @@ async def test_close_with_comment_success(self):
mock_gitctx.patch.assert_called_once_with(
"/repos/test/repo/issues/42",
data={"state": "closed"},
headers={"Accept": "application/vnd.github.html+json"},
headers={"Accept": "application/vnd.github.full+json"},
)


Expand Down
8 changes: 4 additions & 4 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"dependencies": {
"@patternfly/react-core": "^5.0.0",
"@patternfly/react-icons": "^5.0.0",
"github-markdown-css": "^5.8.1",
"github-markdown-css": "^5.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.0",
Expand Down
Loading
Loading