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
110 changes: 110 additions & 0 deletions backend/src/github_pm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,15 @@ class AddDependency(BaseModel):
issue_number: int = Field(title="Related Issue Number")


class AddCloseReference(BaseModel):
"""Request body for adding a pull request as a closing reference.

Generated-by: openai-code-assist
"""

pull_request_number: int = Field(title="Pull Request Number")


def _dependency_link_payload(issue: dict) -> dict:
"""Normalize a GitHub REST issue into the Planning dependency shape.

Expand Down Expand Up @@ -1041,6 +1050,107 @@ def _get_issue_for_dependency(gitctx: Connector, issue_number: int) -> dict:
return issue


def _get_issue_or_pull_request(gitctx: Connector, number: int) -> dict:
"""Fetch an issue or pull request and translate a missing item to 404.

Generated-by: openai-code-assist
"""
try:
return gitctx.get(f"/repos/{context.github_repo}/issues/{number}")
except requests.HTTPError as exc:
status = getattr(exc.response, "status_code", None)
if status == 404:
raise HTTPException(
status_code=404, detail=f"Issue #{number} not found"
) from exc
raise


def _close_reference_payload(pull_request: dict) -> dict:
"""Normalize a pull request into the Planning closed-by link shape.

Generated-by: openai-code-assist
"""
return {
"number": pull_request["number"],
"title": pull_request.get("title"),
"url": pull_request.get("html_url") or pull_request.get("url"),
}


def _add_close_reference(gitctx: Connector, issue: dict, pull_request: dict) -> dict:
"""Manually link a pull request as a closing reference for an issue.

Generated-by: openai-code-assist
"""
if not issue.get("node_id") or not pull_request.get("node_id"):
raise HTTPException(
status_code=502,
detail="GitHub did not return node IDs for the issue and pull request",
)
response = gitctx.post(
"/graphql",
data={
"query": """
mutation($issueId: ID!, $pullRequestIds: [ID!]!) {
addCloseIssueReferences(
input: {issueId: $issueId, pullRequestIds: $pullRequestIds}
) {
issue {
number
}
}
}
""",
"variables": {
"issueId": issue["node_id"],
"pullRequestIds": [pull_request["node_id"]],
},
},
)
errors = response.get("errors") or []
if errors:
detail = "; ".join(
str(error.get("message", error)) if isinstance(error, dict) else str(error)
for error in errors
)
raise HTTPException(status_code=502, detail=detail)
return _close_reference_payload(pull_request)


@api_router.post("/issues/{issue_number}/close-references")
async def add_close_reference(
gitctx: Annotated[Connector, Depends(connection)],
issue_number: Annotated[int, Path(title="Issue")],
body: Annotated[AddCloseReference, Body(title="Closing Reference")],
):
"""Manually link a pull request as closing this issue.

Generated-by: openai-code-assist
"""
issue = _get_issue_or_pull_request(gitctx, issue_number)
if "pull_request" in issue:
raise HTTPException(
status_code=422,
detail=(
f"#{issue_number} is a pull request; "
"closing references require issues"
),
)
pull_request = _get_issue_or_pull_request(gitctx, body.pull_request_number)
if "pull_request" not in pull_request:
raise HTTPException(
status_code=422,
detail=f"#{body.pull_request_number} is not a pull request",
)
linked = _add_close_reference(gitctx, issue, pull_request)
return {
"issue_number": issue_number,
"relationship": "closed_by",
"linked_pull_request": linked,
}


def _dependency_already_exists(exc: requests.HTTPError) -> bool:
"""True when GitHub rejects a duplicate blocked-by edge.

Expand Down
69 changes: 69 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
from github_pm.api import (
add_blocked_by,
add_blocking,
add_close_reference,
add_label_to_issue,
add_milestone_to_issue,
AddCloseReference,
AddDependency,
adopt_parent_milestone,
api_router,
Expand Down Expand Up @@ -1445,6 +1447,73 @@ async def test_remove_blocking(self):
)


class TestCloseReferences:
"""Test manually linking a pull request as an issue's closing reference."""

@pytest.mark.asyncio
async def test_add_close_reference(self):
mock_gitctx = Mock(spec=Connector)
mock_gitctx.get = Mock(
side_effect=[
{"id": 1, "node_id": "I_issue", "number": 1, "title": "Bug"},
{
"id": 17,
"node_id": "PR_pull_request",
"number": 17,
"title": "Fix bug",
"html_url": "https://github.com/test/repo/pull/17",
"pull_request": {},
},
]
)
mock_gitctx.post = Mock(
return_value={"data": {"addCloseIssueReferences": {"issue": {"number": 1}}}}
)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"
result = await add_close_reference(
mock_gitctx, 1, AddCloseReference(pull_request_number=17)
)

assert result == {
"issue_number": 1,
"relationship": "closed_by",
"linked_pull_request": {
"number": 17,
"title": "Fix bug",
"url": "https://github.com/test/repo/pull/17",
},
}
mock_gitctx.post.assert_called_once()
payload = mock_gitctx.post.call_args.kwargs["data"]
assert payload["variables"] == {
"issueId": "I_issue",
"pullRequestIds": ["PR_pull_request"],
}
assert "addCloseIssueReferences" in payload["query"]

@pytest.mark.asyncio
async def test_add_close_reference_rejects_issue_target(self):
mock_gitctx = Mock(spec=Connector)
mock_gitctx.get = Mock(
side_effect=[
{"id": 1, "node_id": "I_issue", "number": 1},
{"id": 17, "node_id": "I_other", "number": 17},
]
)

with patch("github_pm.api.context") as mock_context:
mock_context.github_repo = "test/repo"
with pytest.raises(HTTPException) as exc:
await add_close_reference(
mock_gitctx, 1, AddCloseReference(pull_request_number=17)
)

assert exc.value.status_code == 422
mock_gitctx.post.assert_not_called()


class TestAdoptParentMilestone:
"""Test POST adopt-parent-milestone."""

Expand Down
68 changes: 59 additions & 9 deletions frontend/src/components/IssueCard.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Generated-by: Cursor
// Assisted-by: Cursor
// Assisted-by: openai-code-assist
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
ExpandableSection,
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
removeBlockedBy,
addBlocking,
removeBlocking,
addClosedBy,
} from '../services/api';
import CommentCard from './CommentCard';
import Reactions from './Reactions';
Expand Down Expand Up @@ -179,6 +181,7 @@ const IssueCard = ({
issue.blocked_by || []
);
const [currentBlocking, setCurrentBlocking] = useState(issue.blocking || []);
const [currentClosedBy, setCurrentClosedBy] = useState(issue.closed_by || []);
const [isLinkMenuOpen, setIsLinkMenuOpen] = useState(false);
const [linkRelation, setLinkRelation] = useState('blocked_by');
const [linkIssueNumber, setLinkIssueNumber] = useState('');
Expand Down Expand Up @@ -282,6 +285,10 @@ const IssueCard = ({
setCurrentBlocking(issue.blocking || []);
}, [issue.blocking]);

useEffect(() => {
setCurrentClosedBy(issue.closed_by || []);
}, [issue.closed_by]);

useEffect(() => {
if (!isCloseMenuOpen || closeActiveTab !== 1) return;

Expand Down Expand Up @@ -1041,10 +1048,15 @@ const IssueCard = ({
textDecoration: opts.dimmed ? 'line-through' : 'none',
});

const notifyLinksChanged = (blockedBy, blocking) => {
const notifyLinksChanged = (
blockedBy,
blocking,
closedBy = currentClosedBy
) => {
if (onIssueUpdate) {
onIssueUpdate({
...issue,
closed_by: closedBy,
blocked_by: blockedBy,
blocking,
});
Expand Down Expand Up @@ -1088,15 +1100,23 @@ const IssueCard = ({
const handleAddLink = async () => {
const parsed = parseInt(String(linkIssueNumber).trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
setLinkError('Enter a valid issue number');
setLinkError(
linkRelation === 'closed_by'
? 'Enter a valid pull request number'
: 'Enter a valid issue number'
);
return;
}
if (parsed === issue.number) {
setLinkError('An issue cannot link to itself');
return;
}
const existing =
linkRelation === 'blocked_by' ? currentBlockedBy : currentBlocking;
linkRelation === 'blocked_by'
? currentBlockedBy
: linkRelation === 'blocking'
? currentBlocking
: currentClosedBy;
if (existing.some((d) => d.number === parsed)) {
setLinkError(`#${parsed} is already linked`);
return;
Expand All @@ -1108,16 +1128,25 @@ const IssueCard = ({
const result =
linkRelation === 'blocked_by'
? await addBlockedBy(issue.number, parsed)
: await addBlocking(issue.number, parsed);
const linked = result.linked_issue;
: linkRelation === 'blocking'
? await addBlocking(issue.number, parsed)
: await addClosedBy(issue.number, parsed);
const linked =
linkRelation === 'closed_by'
? result.linked_pull_request
: result.linked_issue;
if (linkRelation === 'blocked_by') {
const next = [...currentBlockedBy, linked];
setCurrentBlockedBy(next);
notifyLinksChanged(next, currentBlocking);
} else {
} else if (linkRelation === 'blocking') {
const next = [...currentBlocking, linked];
setCurrentBlocking(next);
notifyLinksChanged(currentBlockedBy, next);
} else {
const next = [...currentClosedBy, linked];
setCurrentClosedBy(next);
notifyLinksChanged(currentBlockedBy, currentBlocking, next);
}
setLinkIssueNumber('');
setIsLinkMenuOpen(false);
Expand Down Expand Up @@ -1223,7 +1252,7 @@ const IssueCard = ({
);
}

const closedBy = issue.closed_by || [];
const closedBy = currentClosedBy;

return (
<div
Expand Down Expand Up @@ -1351,6 +1380,19 @@ const IssueCard = ({
>
Blocking
</Button>
<Button
variant={
linkRelation === 'closed_by' ? 'primary' : 'secondary'
}
onClick={() => setLinkRelation('closed_by')}
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
flex: 1,
}}
>
Closed by
</Button>
</div>
<TextInput
value={linkIssueNumber}
Expand All @@ -1361,8 +1403,16 @@ const IssueCard = ({
: value?.target?.value || '';
setLinkIssueNumber(stringValue);
}}
placeholder="Issue number"
aria-label="Related issue number"
placeholder={
linkRelation === 'closed_by'
? 'Pull request number'
: 'Issue number'
}
aria-label={
linkRelation === 'closed_by'
? 'Pull request number'
: 'Related issue number'
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
Expand Down
Loading
Loading