From a7a9c445fc9cdaad07784f04a16a72cbe6bc0088 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Mon, 21 Sep 2026 11:31:47 -0400 Subject: [PATCH] Implement Planning view edit of issue title Double-click on title to edit. Signed-off-by: David Butenhof --- backend/src/github_pm/api.py | 28 ++++ backend/tests/test_api.py | 31 ++++ frontend/src/components/IssueCard.jsx | 167 ++++++++++++++++++++- frontend/src/components/IssueCard.test.jsx | 74 +++++++++ frontend/src/services/api.js | 14 ++ frontend/src/services/api.test.js | 14 ++ 6 files changed, 327 insertions(+), 1 deletion(-) diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index daf8132..336cd5b 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -475,6 +475,34 @@ async def update_issue_body( return updated +class UpdateIssueTitle(BaseModel): + """Body for updating an issue title. + + Assisted-by: openai-code-assist + """ + + title: str = Field(title="Issue Title", min_length=1) + + +@api_router.patch("/issues/{issue_number}/title") +async def update_issue_title( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + payload: Annotated[UpdateIssueTitle, Body(title="Issue Title")], +): + """Update an issue's title. + + Assisted-by: openai-code-assist + """ + updated = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"title": payload.title}, + headers=_GITHUB_BODY_ACCEPT, + ) + logger.info("Updated title for issue #%s", issue_number) + return updated + + class CloseIssueRequest(BaseModel): """Body for closing an issue with a reason and optional comment. diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index adc8331..a61dcbb 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -50,8 +50,10 @@ SetIssueParent, update_comment, update_issue_body, + update_issue_title, UpdateComment, UpdateIssueBody, + UpdateIssueTitle, ) from github_pm.app import app @@ -1978,6 +1980,35 @@ async def test_update_issue_body_allows_empty(self): ) +class TestUpdateIssueTitle: + """Test the update_issue_title endpoint. + + Assisted-by: openai-code-assist + """ + + @pytest.mark.asyncio + async def test_update_issue_title_success(self): + mock_issue = { + "number": 42, + "title": "Updated title", + } + 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_title( + mock_gitctx, 42, UpdateIssueTitle(title="Updated title") + ) + + assert result == mock_issue + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/42", + data={"title": "Updated title"}, + headers={"Accept": "application/vnd.github.full+json"}, + ) + + class TestCloseIssueWithComment: """Test the close_issue_with_comment endpoint. diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index 7eb663c..5d9b7ad 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -50,6 +50,7 @@ import { createIssue, renderMarkdown, updateIssueBody, + updateIssueTitle, addBlockedBy, removeBlockedBy, addBlocking, @@ -165,6 +166,10 @@ const IssueCard = ({ const assigneesToggleRef = useRef(null); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(issue.title || ''); + const [titleError, setTitleError] = useState(null); + const [titleBusy, setTitleBusy] = useState(false); const [isCreateSubIssueOpen, setIsCreateSubIssueOpen] = useState(false); const [commentCount, setCommentCount] = useState(issue.comments || 0); const [descriptionBody, setDescriptionBody] = useState(issue.body || ''); @@ -193,12 +198,26 @@ const IssueCard = ({ const closeMenuRef = useRef(null); const closeToggleRef = useRef(null); const closePreviewRequestId = useRef(0); + const titleInputRef = useRef(null); useEffect(() => { setDescriptionBody(issue.body || ''); setDescriptionHtml(issue.body_html || ''); }, [issue.body, issue.body_html, issue.id]); + useEffect(() => { + setTitleDraft(issue.title || ''); + setIsEditingTitle(false); + setTitleError(null); + setTitleBusy(false); + }, [issue.title, issue.id]); + + useEffect(() => { + if (!isEditingTitle || !titleInputRef.current) return; + titleInputRef.current.focus(); + titleInputRef.current.select?.(); + }, [isEditingTitle]); + useEffect(() => { if ( isCommentsExpanded && @@ -900,6 +919,50 @@ const IssueCard = ({ } }; + const beginTitleEdit = () => { + if (issue.pull_request) return; + setTitleDraft(issue.title || ''); + setTitleError(null); + setIsEditingTitle(true); + }; + + const cancelTitleEdit = () => { + setTitleDraft(issue.title || ''); + setTitleError(null); + setTitleBusy(false); + setIsEditingTitle(false); + }; + + const handleUpdateTitle = async () => { + const trimmedTitle = String(titleDraft || '').trim(); + if (!trimmedTitle) { + setTitleError('Title is required'); + return; + } + if (trimmedTitle === issue.title) { + cancelTitleEdit(); + return; + } + + setTitleBusy(true); + setTitleError(null); + try { + const updated = await updateIssueTitle(issue.number, trimmedTitle); + setTitleDraft(updated.title || trimmedTitle); + setIsEditingTitle(false); + if (onIssueUpdate) { + onIssueUpdate({ + ...issue, + title: updated.title || trimmedTitle, + }); + } + } catch (err) { + setTitleError(err.message); + } finally { + setTitleBusy(false); + } + }; + const handleCommentUpdated = (updatedComment) => { setComments((prev) => prev.map((c) => (c.id === updatedComment.id ? updatedComment : c)) @@ -2186,7 +2249,109 @@ const IssueCard = ({ )} - {issue.title} + {isEditingTitle ? ( +
e.stopPropagation()} + > + { + const stringValue = + typeof value === 'string' + ? value + : value?.target?.value || ''; + setTitleDraft(stringValue); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleUpdateTitle(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + validated={titleError ? 'error' : 'default'} + /> +
+ + + {titleError && ( + + {titleError} + + )} +
+
+ ) : ( + { + if ( + issue.pull_request || + (event.key !== 'Enter' && event.key !== ' ') + ) { + return; + } + event.preventDefault(); + beginTitleEdit(); + }} + tabIndex={issue.pull_request ? undefined : 0} + role={issue.pull_request ? undefined : 'button'} + aria-label={ + issue.pull_request + ? undefined + : `Issue title for #${issue.number}. Double-click or press Enter to edit` + } + title={ + issue.pull_request + ? undefined + : 'Double-click to edit title' + } + > + {titleDraft || issue.title} + + )} {isClosableIssue && (
{ }); }); + it('opens inline title editor on double click', async () => { + const user = userEvent.setup(); + + await act(async () => { + render(); + }); + + await user.dblClick(screen.getByText(mockIssue.title)); + + expect( + screen.getByRole('textbox', { name: 'Edit title for issue #459' }) + ).toHaveValue(mockIssue.title); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('updates issue title on OK', async () => { + const user = userEvent.setup(); + const onIssueUpdate = vi.fn(); + api.updateIssueTitle.mockResolvedValue({ + ...mockIssue, + title: 'Updated issue title', + }); + + await act(async () => { + render(); + }); + + await user.dblClick(screen.getByText(mockIssue.title)); + const input = screen.getByRole('textbox', { + name: 'Edit title for issue #459', + }); + await user.clear(input); + await user.type(input, 'Updated issue title'); + await user.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => { + expect(api.updateIssueTitle).toHaveBeenCalledWith( + 459, + 'Updated issue title' + ); + expect(onIssueUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Updated issue title', + }) + ); + expect(screen.getByText('Updated issue title')).toBeInTheDocument(); + }); + }); + + it('cancels inline title editing without updating', async () => { + const user = userEvent.setup(); + + await act(async () => { + render(); + }); + + await user.dblClick(screen.getByText(mockIssue.title)); + const input = screen.getByRole('textbox', { + name: 'Edit title for issue #459', + }); + await user.clear(input); + await user.type(input, 'Discarded title'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => { + expect( + screen.queryByRole('textbox', { name: 'Edit title for issue #459' }) + ).not.toBeInTheDocument(); + }); + expect(api.updateIssueTitle).not.toHaveBeenCalled(); + expect(screen.getByText(mockIssue.title)).toBeInTheDocument(); + }); + it('opens comment modal from Add Comment', async () => { const user = userEvent.setup(); await act(async () => { diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 17acbe6..c0de3a5 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -98,6 +98,20 @@ export const updateIssueBody = async (issueNumber, body) => { return response.json(); }; +export const updateIssueTitle = async (issueNumber, title) => { + const response = await fetch(`${API_BASE}/issues/${issueNumber}/title`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title }), + }); + if (!response.ok) { + throw new Error(`Failed to update issue title: ${response.statusText}`); + } + return response.json(); +}; + export const closeIssue = async (issueNumber, { reason, body }) => { const response = await fetch( `${API_BASE}/issues/${issueNumber}/close-with-comment`, diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 1c02af1..b6b1578 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -22,6 +22,7 @@ import { createIssue, updateComment, updateIssueBody, + updateIssueTitle, } from './api'; describe('api', () => { @@ -371,6 +372,19 @@ describe('api', () => { }); }); + it('updateIssueTitle PATCHes title', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ number: 42, title: 'New title' }), + }); + await updateIssueTitle(42, 'New title'); + expect(global.fetch).toHaveBeenCalledWith('/api/v1/issues/42/title', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'New title' }), + }); + }); + it('closeIssue POSTs reason and body', async () => { global.fetch.mockResolvedValue({ ok: true,