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
28 changes: 28 additions & 0 deletions backend/src/github_pm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
31 changes: 31 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@
SetIssueParent,
update_comment,
update_issue_body,
update_issue_title,
UpdateComment,
UpdateIssueBody,
UpdateIssueTitle,
)
from github_pm.app import app

Expand Down Expand Up @@ -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.

Expand Down
167 changes: 166 additions & 1 deletion frontend/src/components/IssueCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
createIssue,
renderMarkdown,
updateIssueBody,
updateIssueTitle,
addBlockedBy,
removeBlockedBy,
addBlocking,
Expand Down Expand Up @@ -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 || '');
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -2186,7 +2249,109 @@ const IssueCard = ({
</span>
</Tooltip>
)}
<span style={{ fontWeight: '500' }}>{issue.title}</span>
{isEditingTitle ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
maxWidth: '100%',
}}
onClick={(e) => e.stopPropagation()}
>
<TextInput
ref={titleInputRef}
aria-label={`Edit title for issue #${issue.number}`}
value={titleDraft}
onChange={(value) => {
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'}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap',
}}
>
<Button
variant="primary"
onClick={handleUpdateTitle}
isLoading={titleBusy}
isDisabled={titleBusy}
>
OK
</Button>
<Button
variant="link"
onClick={cancelTitleEdit}
isDisabled={titleBusy}
>
Cancel
</Button>
{titleError && (
<span
style={{
color: '#c9190b',
fontSize: '0.875rem',
}}
>
{titleError}
</span>
)}
</div>
</div>
) : (
<span
style={{
fontWeight: '500',
cursor: issue.pull_request ? 'default' : 'text',
}}
onDoubleClick={beginTitleEdit}
onKeyDown={(event) => {
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}
</span>
)}
</div>
{isClosableIssue && (
<div
Expand Down
74 changes: 74 additions & 0 deletions frontend/src/components/IssueCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,80 @@ describe('IssueCard', () => {
});
});

it('opens inline title editor on double click', async () => {
const user = userEvent.setup();

await act(async () => {
render(<IssueCard issue={mockIssue} />);
});

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(<IssueCard issue={mockIssue} onIssueUpdate={onIssueUpdate} />);
});

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(<IssueCard issue={mockIssue} />);
});

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 () => {
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading
Loading