diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index fd43675..c3dcbb6 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -31,6 +31,11 @@ _GITHUB_504_MAX_ATTEMPTS = 5 _GITHUB_504_BACKOFF_SEC = 1.5 +# full+json returns raw markdown ``body`` and rendered ``body_html``. +# html+json alone omits ``body``, which breaks edit-in-place flows. +# Assisted-by: Cursor +_GITHUB_BODY_ACCEPT = {"Accept": "application/vnd.github.full+json"} + class Connector: def __init__(self, github_token: str, *, github_repo: str | None = None): @@ -255,7 +260,7 @@ async def get_issues( milestone = "none" if milestone_number == 0 else milestone_number raw_items = gitctx.get_paged( f"/repos/{context.github_repo}/issues?milestone={milestone}&state=open", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) issues: list[dict] = [] pull_requests: list[dict] = [] @@ -314,7 +319,7 @@ async def get_issue( ): issue = gitctx.get( f"/repos/{context.github_repo}/issues/{issue_number}", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) if "pull_request" not in issue: query = """query($owner: String!, $repo: String!, $issue: Int!) { @@ -370,7 +375,7 @@ async def get_comments( start = time.time() comments = gitctx.get_paged( f"/repos/{context.github_repo}/issues/{issue_number}/comments", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) logger.debug( f"{len(comments)} issue {issue_number} comments: {time.time() - start:.3f} seconds" @@ -400,12 +405,68 @@ async def create_comment( created = gitctx.post( f"/repos/{context.github_repo}/issues/{issue_number}/comments", data={"body": comment.body}, - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) logger.info("Created comment on issue #%s", issue_number) return created +class UpdateComment(BaseModel): + """Body for updating an issue comment. + + Generated-by: Cursor + """ + + body: str = Field(title="Comment Body", min_length=1) + + +@api_router.patch("/comments/{comment_id}/body") +async def update_comment( + gitctx: Annotated[Connector, Depends(connection)], + comment_id: Annotated[int, Path(title="Comment ID")], + comment: Annotated[UpdateComment, Body(title="Comment")], +): + """Update an issue comment's markdown body. + + Generated-by: Cursor + """ + updated = gitctx.patch( + f"/repos/{context.github_repo}/issues/comments/{comment_id}", + data={"body": comment.body}, + headers=_GITHUB_BODY_ACCEPT, + ) + logger.info("Updated comment %s", comment_id) + return updated + + +class UpdateIssueBody(BaseModel): + """Body for updating an issue description. + + Generated-by: Cursor + """ + + body: str = Field(title="Issue Body", default="") + + +@api_router.patch("/issues/{issue_number}/body") +async def update_issue_body( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + payload: Annotated[UpdateIssueBody, Body(title="Issue Body")], +): + """Update an issue's markdown description. + + Generated-by: Cursor + """ + updated = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"body": payload.body}, + headers=_GITHUB_BODY_ACCEPT, + ) + logger.info("Updated body for issue #%s", issue_number) + return updated + + class CloseWithComment(BaseModel): """Body for closing an issue with an optional comment. @@ -428,12 +489,12 @@ async def close_issue_with_comment( created_comment = gitctx.post( f"/repos/{context.github_repo}/issues/{issue_number}/comments", data={"body": comment.body}, - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) closed_issue = gitctx.patch( f"/repos/{context.github_repo}/issues/{issue_number}", data={"state": "closed"}, - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) logger.info("Closed issue #%s with comment", issue_number) return {"comment": created_comment, "issue": closed_issue} @@ -508,7 +569,7 @@ async def create_issue( created = gitctx.post( f"/repos/{context.github_repo}/issues", data=data, - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) logger.info("Created issue #%s: %s", created.get("number"), issue.title) @@ -538,7 +599,7 @@ async def get_issue_reactions( start = time.time() reactions = gitctx.get_paged( f"/repos/{context.github_repo}/issues/{issue_number}/reactions", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) logger.debug( f"{len(reactions)} issue {issue_number} reactions: {time.time() - start:.3f} seconds" @@ -553,7 +614,7 @@ async def get_comment_reactions( ): reactions = gitctx.get_paged( f"/repos/{context.github_repo}/issues/comments/{comment_id}/reactions", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) return reactions @@ -565,7 +626,7 @@ async def get_comment_reactions( async def get_milestones(gitctx: Annotated[Connector, Depends(connection)]): milestones = gitctx.get_paged( f"/repos/{context.github_repo}/milestones", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) versions = [] others = [] @@ -827,7 +888,7 @@ async def adopt_parent_milestone( async def get_labels(gitctx: Annotated[Connector, Depends(connection)]): labels = gitctx.get_paged( f"/repos/{context.github_repo}/labels", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) return labels @@ -904,7 +965,7 @@ async def get_assignees(gitctx: Annotated[Connector, Depends(connection)]): """Get all allowed assignees for the repository""" assignees = gitctx.get_paged( f"/repos/{context.github_repo}/assignees", - headers={"Accept": "application/vnd.github.html+json"}, + headers=_GITHUB_BODY_ACCEPT, ) return sorted(assignees, key=lambda x: x["login"]) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 943bbcb..596463a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -43,6 +43,10 @@ RenderMarkdown, set_issue_parent, SetIssueParent, + update_comment, + update_issue_body, + UpdateComment, + UpdateIssueBody, ) from github_pm.app import app @@ -1658,7 +1662,84 @@ async def test_create_comment_success(self): mock_gitctx.post.assert_called_once_with( "/repos/test/repo/issues/42/comments", data={"body": "Hello"}, - headers={"Accept": "application/vnd.github.html+json"}, + headers={"Accept": "application/vnd.github.full+json"}, + ) + + +class TestUpdateComment: + """Test the update_comment endpoint. + + Generated-by: Cursor + """ + + @pytest.mark.asyncio + async def test_update_comment_success(self): + mock_comment = { + "id": 99, + "body": "Updated", + "body_html": "
Updated
", + } + mock_gitctx = Mock(spec=Connector) + mock_gitctx.patch = Mock(return_value=mock_comment) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await update_comment( + mock_gitctx, 99, UpdateComment(body="Updated") + ) + + assert result == mock_comment + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/comments/99", + data={"body": "Updated"}, + headers={"Accept": "application/vnd.github.full+json"}, + ) + + +class TestUpdateIssueBody: + """Test the update_issue_body endpoint. + + Generated-by: Cursor + """ + + @pytest.mark.asyncio + async def test_update_issue_body_success(self): + mock_issue = { + "number": 42, + "body": "New description", + "body_html": "New description
", + } + 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_body( + mock_gitctx, 42, UpdateIssueBody(body="New description") + ) + + assert result == mock_issue + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/42", + data={"body": "New description"}, + headers={"Accept": "application/vnd.github.full+json"}, + ) + + @pytest.mark.asyncio + async def test_update_issue_body_allows_empty(self): + mock_issue = {"number": 42, "body": "", "body_html": ""} + 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_body(mock_gitctx, 42, UpdateIssueBody(body="")) + + assert result == mock_issue + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/42", + data={"body": ""}, + headers={"Accept": "application/vnd.github.full+json"}, ) @@ -1687,7 +1768,7 @@ async def test_close_with_comment_success(self): mock_gitctx.patch.assert_called_once_with( "/repos/test/repo/issues/42", data={"state": "closed"}, - headers={"Accept": "application/vnd.github.html+json"}, + headers={"Accept": "application/vnd.github.full+json"}, ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a30c3b0..cb6a3c9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@patternfly/react-core": "^5.0.0", "@patternfly/react-icons": "^5.0.0", - "github-markdown-css": "^5.8.1", + "github-markdown-css": "^5.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.0", @@ -2756,9 +2756,9 @@ } }, "node_modules/github-markdown-css": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz", - "integrity": "sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.9.0.tgz", + "integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==", "engines": { "node": ">=10" }, diff --git a/frontend/package.json b/frontend/package.json index b523af5..42845c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,7 @@ "dependencies": { "@patternfly/react-core": "^5.0.0", "@patternfly/react-icons": "^5.0.0", - "github-markdown-css": "^5.8.1", + "github-markdown-css": "^5.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.0", diff --git a/frontend/src/components/CommentCard.jsx b/frontend/src/components/CommentCard.jsx index 2412d48..ba879f6 100644 --- a/frontend/src/components/CommentCard.jsx +++ b/frontend/src/components/CommentCard.jsx @@ -1,16 +1,27 @@ // Generated-by: Cursor +// Assisted-by: Cursor import React, { useState, useEffect } from 'react'; -import { Spinner, Alert } from '@patternfly/react-core'; +import { Spinner, Alert, Button, Tooltip } from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; import { getDaysSince, formatDate } from '../utils/dateUtils'; -import { fetchCommentReactions } from '../services/api'; +import { fetchCommentReactions, updateComment } from '../services/api'; import Reactions from './Reactions'; import UserAvatar from './UserAvatar'; +import MarkdownInputModal from './MarkdownInputModal'; -const CommentCard = ({ comment }) => { +const CommentCard = ({ comment, onCommentUpdated }) => { const daysSince = getDaysSince(comment.created_at); const [reactions, setReactions] = useState([]); const [reactionsLoading, setReactionsLoading] = useState(false); const [reactionsError, setReactionsError] = useState(null); + const [isEditOpen, setIsEditOpen] = useState(false); + const [body, setBody] = useState(comment.body || ''); + const [bodyHtml, setBodyHtml] = useState(comment.body_html || ''); + + useEffect(() => { + setBody(comment.body || ''); + setBodyHtml(comment.body_html || ''); + }, [comment.body, comment.body_html, comment.id]); // Reset reactions when comment changes useEffect(() => { @@ -46,6 +57,13 @@ const CommentCard = ({ comment }) => { reactionsLoading, ]); + const handleUpdateComment = async (nextBody) => { + const updated = await updateComment(comment.id, nextBody); + setBody(updated.body || ''); + setBodyHtml(updated.body_html || ''); + onCommentUpdated?.(updated); + }; + return (Updated body
', + }; + api.updateComment.mockResolvedValue(updated); + + render( +- No description. -
- )} -+ No description. +
+ )} +Updated description
', + }); + + await act(async () => { + render(Updated description
', + }) + ); + expect(screen.getByText('Updated description')).toBeInTheDocument(); + }); + }); + it('opens comment modal from Add Comment', async () => { const user = userEvent.setup(); await act(async () => { diff --git a/frontend/src/components/MarkdownInputModal.jsx b/frontend/src/components/MarkdownInputModal.jsx index 4e11789..d1c0a54 100644 --- a/frontend/src/components/MarkdownInputModal.jsx +++ b/frontend/src/components/MarkdownInputModal.jsx @@ -30,6 +30,7 @@ const ISSUE_TYPES = ['Bug', 'Feature']; * - issue: title, type, labels, assignees + body; action Submit + Cancel * * Generated-by: Cursor + * Assisted-by: Cursor */ const MarkdownInputModal = ({ isOpen, @@ -40,6 +41,9 @@ const MarkdownInputModal = ({ onSubmit, showCloseWithComment = false, onCloseWithComment, + initialBody = '', + bodyRequired, + bodyLabel, }) => { const [activeTab, setActiveTab] = useState(0); const [body, setBody] = useState(''); @@ -58,6 +62,10 @@ const MarkdownInputModal = ({ const [isSubmitting, setIsSubmitting] = useState(false); const textareaRef = useRef(null); const previewRequestId = useRef(0); + const isBodyRequired = + bodyRequired !== undefined ? bodyRequired : mode === 'comment'; + const resolvedBodyLabel = + bodyLabel || (mode === 'issue' ? 'Description' : 'Comment'); const resetState = () => { setActiveTab(0); @@ -77,6 +85,17 @@ const MarkdownInputModal = ({ resetState(); return; } + setActiveTab(0); + setBody(initialBody || ''); + setIssueTitle(''); + setIssueType('Feature'); + setSelectedLabels([]); + setSelectedAssignees([]); + setPreviewHtml(''); + setPreviewError(null); + setSubmitError(null); + setIsSubmitting(false); + if (mode !== 'issue') return; // Load labels @@ -106,7 +125,7 @@ const MarkdownInputModal = ({ }) .catch(() => setAssigneesLoading(false)); } - }, [isOpen, mode]); + }, [isOpen, mode, initialBody]); useEffect(() => { if (!isOpen || activeTab !== 1) return; @@ -146,7 +165,7 @@ const MarkdownInputModal = ({ setSubmitError('Title is required'); return; } - if (mode === 'comment' && !body.trim()) { + if (isBodyRequired && !body.trim()) { setSubmitError('Comment body is required'); return; } @@ -202,7 +221,8 @@ const MarkdownInputModal = ({ onClick={handleSubmit} isLoading={isSubmitting} isDisabled={ - isSubmitting || (mode === 'issue' ? !issueTitle.trim() : !body.trim()) + isSubmitting || + (mode === 'issue' ? !issueTitle.trim() : isBodyRequired && !body.trim()) } > {submitLabel} @@ -388,9 +408,9 @@ const MarkdownInputModal = ({ )}