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 ? ( +