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
123 changes: 103 additions & 20 deletions backend/src/github_pm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
# We sort "semver" style milestones first, then others alphabetically
VERSION_MATCH = re.compile(r"^v\d+\.\d+\.\d+$")


# Assisted-by: openai-code-assist
def _milestone_sort_key(milestone: dict[str, Any]) -> tuple:
"""Sort version milestones numerically before other milestones."""
title = milestone.get("title") or ""
version_match = VERSION_MATCH.fullmatch(title)
if version_match:
return (
0,
tuple(int(part) for part in version_match.group(0)[1:].split(".")),
"",
)
return (1, (), title)


# Bounded retries for transient GitHub gateway timeouts (504).
_GITHUB_504_MAX_ATTEMPTS = 5
_GITHUB_504_BACKOFF_SEC = 1.5
Expand Down Expand Up @@ -664,29 +679,27 @@ async def get_comment_reactions(


@api_router.get("/milestones")
async def get_milestones(gitctx: Annotated[Connector, Depends(connection)]):
async def get_milestones(
gitctx: Annotated[Connector, Depends(connection)],
state: Annotated[
Literal["open", "closed"],
Query(title="Milestone State"),
] = "open",
):
milestones = gitctx.get_paged(
f"/repos/{context.github_repo}/milestones",
f"/repos/{context.github_repo}/milestones?state={state}",
headers=_GITHUB_BODY_ACCEPT,
)
versions = []
others = []
for m in milestones:
if VERSION_MATCH.match(m["title"]):
versions.append(m)
else:
others.append(m)
milestones = sorted(versions, key=lambda x: x["title"]) + sorted(
others, key=lambda x: x["title"]
)
milestones.append(
{
"title": "none",
"number": 0,
"description": "No milestone",
"due_on": None,
}
)
milestones = sorted(milestones, key=_milestone_sort_key)
if state == "open":
milestones.append(
{
"title": "none",
"number": 0,
"description": "No milestone",
"due_on": None,
}
)
return milestones


Expand All @@ -703,6 +716,11 @@ class UpdateMilestone(BaseModel):
due_on: datetime | None = Field(default=None, title="Milestone Due Date")


# Assisted-by: openai-code-assist
class UpdateMilestoneState(BaseModel):
state: Literal["open", "closed"] = Field(title="Milestone State")


@api_router.post("/milestones")
async def create_milestone(
gitctx: Annotated[Connector, Depends(connection)],
Expand Down Expand Up @@ -738,6 +756,38 @@ async def update_milestone(
)


# Assisted-by: openai-code-assist
@api_router.get("/milestones/{milestone_number}/open-counts")
async def get_milestone_open_counts(
gitctx: Annotated[Connector, Depends(connection)],
milestone_number: Annotated[int, Path(title="Milestone")],
):
"""Return open issue and pull-request counts for a milestone."""
items = gitctx.get_paged(
f"/repos/{context.github_repo}/issues?milestone={milestone_number}&state=open",
headers=_GITHUB_BODY_ACCEPT,
)
open_pull_requests = sum("pull_request" in item for item in items)
return {
"open_issues": len(items) - open_pull_requests,
"open_pull_requests": open_pull_requests,
}


# Assisted-by: openai-code-assist
@api_router.patch("/milestones/{milestone_number}/state")
async def update_milestone_state(
gitctx: Annotated[Connector, Depends(connection)],
milestone_number: Annotated[int, Path(title="Milestone")],
milestone: Annotated[UpdateMilestoneState, Body(title="Milestone State")],
):
"""Open or close a milestone without modifying its other fields."""
return gitctx.patch(
f"/repos/{context.github_repo}/milestones/{milestone_number}",
data={"state": milestone.state},
)


@api_router.delete("/milestones/{milestone_number}")
async def delete_milestone(
gitctx: Annotated[Connector, Depends(connection)],
Expand Down Expand Up @@ -1141,6 +1191,17 @@ class CreateLabel(BaseModel):
description: str | None = Field(default=None, title="Label Description")


class UpdateLabel(BaseModel):
"""Body for updating a repository label.

Assisted-by: openai-code-assist
"""

name: str = Field(title="Label Name")
color: str | None = Field(default=None, title="Label Color")
description: str | None = Field(default=None, title="Label Description")


@api_router.post("/labels")
async def create_label(
gitctx: Annotated[Connector, Depends(connection)],
Expand All @@ -1157,6 +1218,28 @@ async def create_label(
return response


@api_router.patch("/labels/{label_name}")
async def update_label(
gitctx: Annotated[Connector, Depends(connection)],
label_name: str,
label: Annotated[UpdateLabel, Body(title="Label")],
):
"""Update a repository label, including its name, color, or description.

Assisted-by: openai-code-assist
"""

response = gitctx.patch(
f"/repos/{context.github_repo}/labels/{label_name}",
data={
"new_name": label.name,
"color": label.color,
"description": label.description,
},
)
return response


@api_router.delete("/labels/{label_name}")
async def delete_label(
gitctx: Annotated[Connector, Depends(connection)], label_name: str
Expand Down
124 changes: 124 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
get_issue_reactions,
get_issues,
get_labels,
get_milestone_open_counts,
get_milestones,
get_project,
remove_blocked_by,
Expand All @@ -51,9 +52,13 @@
update_comment,
update_issue_body,
update_issue_title,
update_label,
update_milestone_state,
UpdateComment,
UpdateIssueBody,
UpdateIssueTitle,
UpdateLabel,
UpdateMilestoneState,
)
from github_pm.app import app

Expand Down Expand Up @@ -893,6 +898,45 @@ async def test_get_milestones(self):
assert result[2]["due_on"] is None
mock_gitctx.get_paged.assert_called_once()

@pytest.mark.asyncio
async def test_get_milestones_sorts_versions_numerically_before_other_milestones(
self,
):
mock_milestones = [
{"title": "v0.9.0", "number": 9},
{"title": "Backlog", "number": 1},
{"title": "v0.10.0", "number": 10},
{"title": "v0.8.1", "number": 8},
]
mock_gitctx = Mock(spec=Connector)
mock_gitctx.get_paged = Mock(return_value=mock_milestones)

result = await get_milestones(mock_gitctx)

assert [milestone["title"] for milestone in result] == [
"v0.8.1",
"v0.9.0",
"v0.10.0",
"Backlog",
"none",
]

@pytest.mark.asyncio
async def test_get_closed_milestones_omits_none_placeholder(self):
mock_milestones = [{"title": "Completed", "number": 3}]
mock_gitctx = Mock(spec=Connector)
mock_gitctx.get_paged = Mock(return_value=mock_milestones)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"
result = await get_milestones(mock_gitctx, state="closed")

assert result == mock_milestones
mock_gitctx.get_paged.assert_called_once_with(
"/repos/test/repo/milestones?state=closed",
headers={"Accept": "application/vnd.github.full+json"},
)


class TestCreateMilestone:
"""Test the create_milestone endpoint."""
Expand Down Expand Up @@ -957,6 +1001,52 @@ async def test_create_milestone_with_defaults(self):
assert "due_on" not in call_args[1]["data"]


class TestMilestoneState:
"""Test milestone state and open-work endpoints."""

@pytest.mark.asyncio
async def test_get_milestone_open_counts(self):
mock_gitctx = Mock(spec=Connector)
mock_gitctx.get_paged = Mock(
return_value=[
{"number": 1},
{"number": 2, "pull_request": {"url": "https://example.test/pr/2"}},
{"number": 3},
]
)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"

result = await get_milestone_open_counts(mock_gitctx, milestone_number=7)

assert result == {"open_issues": 2, "open_pull_requests": 1}
mock_gitctx.get_paged.assert_called_once_with(
"/repos/test/repo/issues?milestone=7&state=open",
headers={"Accept": "application/vnd.github.full+json"},
)

@pytest.mark.asyncio
async def test_update_milestone_state(self):
mock_response = {"number": 7, "state": "closed"}
mock_gitctx = Mock(spec=Connector)
mock_gitctx.patch = Mock(return_value=mock_response)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"

result = await update_milestone_state(
mock_gitctx,
milestone_number=7,
milestone=UpdateMilestoneState(state="closed"),
)

assert result == mock_response
mock_gitctx.patch.assert_called_once_with(
"/repos/test/repo/milestones/7", data={"state": "closed"}
)


class TestDeleteMilestone:
"""Test the delete_milestone endpoint."""

Expand Down Expand Up @@ -1519,6 +1609,40 @@ async def test_delete_label_not_found(self):
await delete_label(mock_gitctx, label_name="nonexistent")


class TestUpdateLabel:
"""Test the update_label endpoint."""

@pytest.mark.asyncio
async def test_update_label_success(self):
"""Test successfully updating a label, including its name."""
mock_label_response = {
"id": 1,
"name": "updated-label",
"color": "green",
"description": "Updated label",
}
label_data = UpdateLabel(
name="updated-label", color="green", description="Updated label"
)
mock_gitctx = Mock(spec=Connector)
mock_gitctx.patch = Mock(return_value=mock_label_response)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"

result = await update_label(mock_gitctx, "old-label", label_data)

assert result == mock_label_response
mock_gitctx.patch.assert_called_once_with(
"/repos/test/repo/labels/old-label",
data={
"new_name": "updated-label",
"color": "green",
"description": "Updated label",
},
)


class TestAddLabelToIssue:
"""Test the add_label_to_issue endpoint."""

Expand Down
Loading
Loading