diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 2a89779..3e35b40 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -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. @@ -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. diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1b9ce57..039598c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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, @@ -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.""" diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index c069cc5..c9a51dd 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -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, @@ -55,6 +56,7 @@ import { removeBlockedBy, addBlocking, removeBlocking, + addClosedBy, } from '../services/api'; import CommentCard from './CommentCard'; import Reactions from './Reactions'; @@ -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(''); @@ -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; @@ -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, }); @@ -1088,7 +1100,11 @@ 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) { @@ -1096,7 +1112,11 @@ const IssueCard = ({ 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; @@ -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); @@ -1223,7 +1252,7 @@ const IssueCard = ({ ); } - const closedBy = issue.closed_by || []; + const closedBy = currentClosedBy; return (
Blocking +
{ if (e.key === 'Enter') { e.preventDefault(); diff --git a/frontend/src/components/IssueCard.test.jsx b/frontend/src/components/IssueCard.test.jsx index 42c4e9d..13e572d 100644 --- a/frontend/src/components/IssueCard.test.jsx +++ b/frontend/src/components/IssueCard.test.jsx @@ -311,6 +311,47 @@ describe('IssueCard', () => { ); }); + it('adds a closed-by pull request from the Links popover', async () => { + const user = userEvent.setup(); + const onIssueUpdate = vi.fn(); + api.addClosedBy.mockResolvedValue({ + issue_number: 459, + relationship: 'closed_by', + linked_pull_request: { + number: 123, + title: 'Fix issue', + url: 'https://github.com/test/repo/pull/123', + }, + }); + await act(async () => { + render( + + + + +
+ ); + }); + await user.click(screen.getByRole('button', { name: 'Add link' })); + await user.click(screen.getByRole('button', { name: 'Closed by' })); + await user.type( + screen.getByRole('textbox', { name: 'Pull request number' }), + '123' + ); + await user.click(screen.getByRole('button', { name: 'Add' })); + await waitFor(() => { + expect(api.addClosedBy).toHaveBeenCalledWith(459, 123); + }); + expect(screen.getByRole('link', { name: '#123' })).toBeInTheDocument(); + expect(onIssueUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + closed_by: [ + expect.objectContaining({ number: 123, title: 'Fix issue' }), + ], + }) + ); + }); + it('renders branch icon in Links column for pull request rows', async () => { const prIssue = { ...mockIssue, diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 08b9308..cd07423 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -424,6 +424,22 @@ export const removeBlocking = async (issueNumber, blockedIssueNumber) => { return response.json(); }; +// Assisted-by: openai-code-assist +export const addClosedBy = async (issueNumber, pullRequestNumber) => { + const response = await fetch( + `${API_BASE}/issues/${issueNumber}/close-references`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pull_request_number: pullRequestNumber }), + } + ); + await raiseForResponse(response, 'Failed to add closed-by link'); + return response.json(); +}; + export const fetchIssueReactions = async (issueNumber) => { const response = await fetch(`${API_BASE}/issues/${issueNumber}/reactions`); if (!response.ok) { diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 1e55200..261581f 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -17,6 +17,7 @@ import { removeBlockedBy, addBlocking, removeBlocking, + addClosedBy, createComment, closeIssue, renderMarkdown, @@ -407,6 +408,22 @@ describe('api', () => { { method: 'DELETE' } ); }); + + it('addClosedBy POSTs the pull request number', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ relationship: 'closed_by' }), + }); + await addClosedBy(1, 17); + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/issues/1/close-references', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pull_request_number: 17 }), + } + ); + }); }); describe('comment and issue write APIs', () => {