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 (
{
- {comment.user?.html_url ? ( - + {comment.user?.html_url ? ( + + +
+ {comment.user.login || 'Unknown'} +
+
+ ) : ( +
+ +
+ {comment.user?.login || 'Unknown'} +
+
+ )} +
- -
- {comment.user.login || 'Unknown'} -
- - ) : ( -
- -
- {comment.user?.login || 'Unknown'} -
+ {formatDate(comment.created_at)} ({daysSince} days ago)
- )} -
- {formatDate(comment.created_at)} ({daysSince} days ago)
+ + +
{comment.reactions?.total_count > 0 && (
@@ -114,6 +157,15 @@ const CommentCard = ({ comment }) => { )}
)} + setIsEditOpen(false)} + mode="comment" + title="Edit comment" + submitLabel="OK" + initialBody={body} + onSubmit={handleUpdateComment} + />
); }; diff --git a/frontend/src/components/CommentCard.test.jsx b/frontend/src/components/CommentCard.test.jsx index 4d233b4..b0d25ae 100644 --- a/frontend/src/components/CommentCard.test.jsx +++ b/frontend/src/components/CommentCard.test.jsx @@ -1,7 +1,12 @@ // Generated-by: Cursor -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +// Assisted-by: Cursor +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import CommentCard from './CommentCard'; +import * as api from '../services/api'; + +vi.mock('../services/api'); describe('CommentCard', () => { const mockComment = { @@ -15,6 +20,10 @@ describe('CommentCard', () => { created_at: '2025-08-03T15:47:13Z', }; + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders comment body as HTML', () => { const { container } = render(); expect(screen.getByText(/Hi @sjmonson/i)).toBeInTheDocument(); @@ -56,4 +65,48 @@ describe('CommentCard', () => { render(); expect(screen.getByText('Unknown')).toBeInTheDocument(); }); + + it('shows edit icon and opens markdown editor with current body', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Edit comment' })); + expect(screen.getByText('Edit comment')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Write markdown…')).toHaveValue( + mockComment.body + ); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + }); + + it('updates comment on OK and notifies parent', async () => { + const user = userEvent.setup(); + const onCommentUpdated = vi.fn(); + const updated = { + ...mockComment, + body: 'Updated body', + body_html: '

Updated body

', + }; + api.updateComment.mockResolvedValue(updated); + + render( + + ); + + await user.click(screen.getByRole('button', { name: 'Edit comment' })); + const textarea = screen.getByPlaceholderText('Write markdown…'); + await user.clear(textarea); + await user.type(textarea, 'Updated body'); + await user.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => { + expect(api.updateComment).toHaveBeenCalledWith( + mockComment.id, + 'Updated body' + ); + expect(onCommentUpdated).toHaveBeenCalledWith(updated); + expect(screen.getByText('Updated body')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index 2f1bade..7bd5228 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -23,6 +23,7 @@ import { TaskIcon, ExclamationTriangleIcon, PlusIcon, + PencilAltIcon, } from '@patternfly/react-icons'; import { getDaysSince, formatDate } from '../utils/dateUtils'; import { @@ -41,6 +42,7 @@ import { createComment, closeIssueWithComment, createIssue, + updateIssueBody, } from '../services/api'; import CommentCard from './CommentCard'; import Reactions from './Reactions'; @@ -151,8 +153,16 @@ const IssueCard = ({ const assigneesMenuRef = useRef(null); const assigneesToggleRef = useRef(null); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); + const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false); const [isCreateSubIssueOpen, setIsCreateSubIssueOpen] = useState(false); const [commentCount, setCommentCount] = useState(issue.comments || 0); + const [descriptionBody, setDescriptionBody] = useState(issue.body || ''); + const [descriptionHtml, setDescriptionHtml] = useState(issue.body_html || ''); + + useEffect(() => { + setDescriptionBody(issue.body || ''); + setDescriptionHtml(issue.body_html || ''); + }, [issue.body, issue.body_html, issue.id]); useEffect(() => { if ( @@ -785,6 +795,25 @@ const IssueCard = ({ } }; + const handleUpdateDescription = async (body) => { + const updated = await updateIssueBody(issue.number, body); + setDescriptionBody(updated.body || ''); + setDescriptionHtml(updated.body_html || ''); + if (onIssueUpdate) { + onIssueUpdate({ + ...issue, + body: updated.body, + body_html: updated.body_html, + }); + } + }; + + const handleCommentUpdated = (updatedComment) => { + setComments((prev) => + prev.map((c) => (c.id === updatedComment.id ? updatedComment : c)) + ); + }; + const handleCloseWithComment = async (body) => { await closeIssueWithComment(issue.number, body); onIssueClosed?.({ issueNumber: issue.number }); @@ -1715,17 +1744,37 @@ const IssueCard = ({ backgroundColor: '#fafafa', }} > - {issue.body_html ? ( -
- ) : ( -

- No description. -

- )} -
+
+
+ {descriptionHtml ? ( +
+ ) : ( +

+ No description. +

+ )} +
+ + + +
+
setIsCommentsExpanded(!isCommentsExpanded)} @@ -1747,7 +1796,11 @@ const IssueCard = ({ comments.length > 0 && (
{comments.map((comment) => ( - + ))}
)} @@ -1815,6 +1868,17 @@ const IssueCard = ({ onSubmit={handleAddComment} onCloseWithComment={handleCloseWithComment} /> + setIsEditDescriptionOpen(false)} + mode="comment" + title={`Edit description for #${issue.number}`} + submitLabel="OK" + bodyLabel="Description" + bodyRequired={false} + initialBody={descriptionBody} + onSubmit={handleUpdateDescription} + /> setIsCreateSubIssueOpen(false)} diff --git a/frontend/src/components/IssueCard.test.jsx b/frontend/src/components/IssueCard.test.jsx index d460b02..839b354 100644 --- a/frontend/src/components/IssueCard.test.jsx +++ b/frontend/src/components/IssueCard.test.jsx @@ -527,6 +527,62 @@ describe('IssueCard', () => { ).toBeInTheDocument(); }); + it('shows edit description icon when expanded and opens editor', async () => { + const user = userEvent.setup(); + await act(async () => { + render(); + }); + expect( + screen.queryByRole('button', { name: 'Edit description' }) + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /show description/i })); + await user.click(screen.getByRole('button', { name: 'Edit description' })); + + expect(screen.getByText('Edit description for #459')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Write markdown…')).toHaveValue( + mockIssue.body + ); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + }); + + it('updates issue description on OK', async () => { + const user = userEvent.setup(); + const onIssueUpdate = vi.fn(); + api.updateIssueBody.mockResolvedValue({ + ...mockIssue, + body: 'Updated description', + body_html: '

Updated description

', + }); + + await act(async () => { + render(); + }); + await user.click(screen.getByRole('button', { name: /show description/i })); + await user.click(screen.getByRole('button', { name: 'Edit description' })); + + const textarea = screen.getByPlaceholderText('Write markdown…'); + await user.clear(textarea); + await user.type(textarea, 'Updated description'); + await user.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => { + expect(api.updateIssueBody).toHaveBeenCalledWith( + 459, + 'Updated description' + ); + expect(onIssueUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'Updated description', + body_html: '

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 = ({ )} { await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(onClose).toHaveBeenCalled(); }); + + it('pre-fills initialBody and submits with OK', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + + ); + + expect(screen.getByPlaceholderText('Write markdown…')).toHaveValue( + 'Existing markdown' + ); + await user.click(screen.getByRole('button', { name: 'OK' })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith('Existing markdown'); + }); + }); + + it('allows empty body when bodyRequired is false', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + + ); + + expect(screen.getByText('Description')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'OK' })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(''); + }); + }); }); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 780747e..60f8e16 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -44,6 +44,34 @@ export const createComment = async (issueNumber, body) => { return response.json(); }; +export const updateComment = async (commentId, body) => { + const response = await fetch(`${API_BASE}/comments/${commentId}/body`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body }), + }); + if (!response.ok) { + throw new Error(`Failed to update comment: ${response.statusText}`); + } + return response.json(); +}; + +export const updateIssueBody = async (issueNumber, body) => { + const response = await fetch(`${API_BASE}/issues/${issueNumber}/body`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body }), + }); + if (!response.ok) { + throw new Error(`Failed to update issue body: ${response.statusText}`); + } + return response.json(); +}; + export const closeIssueWithComment = async (issueNumber, 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 94249d7..a184d6e 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -16,6 +16,8 @@ import { closeIssueWithComment, renderMarkdown, createIssue, + updateComment, + updateIssueBody, } from './api'; describe('api', () => { @@ -281,6 +283,32 @@ describe('api', () => { }); }); + it('updateComment PATCHes body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: 99, body: 'Updated' }), + }); + await updateComment(99, 'Updated'); + expect(global.fetch).toHaveBeenCalledWith('/api/v1/comments/99/body', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: 'Updated' }), + }); + }); + + it('updateIssueBody PATCHes body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ number: 42, body: 'New desc' }), + }); + await updateIssueBody(42, 'New desc'); + expect(global.fetch).toHaveBeenCalledWith('/api/v1/issues/42/body', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: 'New desc' }), + }); + }); + it('closeIssueWithComment POSTs body', async () => { global.fetch.mockResolvedValue({ ok: true,