diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 3e35b40..4bb5771 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -518,6 +518,30 @@ async def update_issue_title( return updated +# Assisted-by: openai-code-assist +class UpdateIssueType(BaseModel): + """Body for updating an issue type.""" + + type: str = Field(title="Issue Type", min_length=1) + + +# Assisted-by: openai-code-assist +@api_router.patch("/issues/{issue_number}/type") +async def update_issue_type( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + payload: Annotated[UpdateIssueType, Body(title="Issue Type")], +): + """Update an issue's GitHub issue type.""" + updated = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"type": payload.type}, + headers=_GITHUB_BODY_ACCEPT, + ) + logger.info("Updated type for issue #%s", issue_number) + return updated + + class CloseIssueRequest(BaseModel): """Body for closing an issue with a reason and optional comment. @@ -591,7 +615,7 @@ class CreateIssue(BaseModel): type: str | None = Field( default=None, title="Issue Type", - description="GitHub issue type name, e.g. Bug or Feature", + description="GitHub issue type name, e.g. Bug, Feature, or Task", ) milestone: int | None = Field(default=None, title="Milestone Number") parent_number: int | None = Field( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 039598c..f7437e8 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -54,11 +54,13 @@ update_comment, update_issue_body, update_issue_title, + update_issue_type, update_label, update_milestone_state, UpdateComment, UpdateIssueBody, UpdateIssueTitle, + UpdateIssueType, UpdateLabel, UpdateMilestoneState, ) @@ -2202,6 +2204,35 @@ async def test_update_issue_title_success(self): ) +class TestUpdateIssueType: + """Test the update_issue_type endpoint. + + Assisted-by: openai-code-assist + """ + + @pytest.mark.asyncio + async def test_update_issue_type_success(self): + mock_issue = { + "number": 42, + "type": {"name": "Task", "color": "green"}, + } + 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_type( + mock_gitctx, 42, UpdateIssueType(type="Task") + ) + + assert result == mock_issue + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/42", + data={"type": "Task"}, + 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 0c850dc..a0cfcea 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -53,6 +53,7 @@ import { renderMarkdown, updateIssueBody, updateIssueTitle, + updateIssueType, addBlockedBy, removeBlockedBy, addBlocking, @@ -68,6 +69,9 @@ import labelsCache, { clearLabelsCache } from '../utils/labelsCache'; import milestonesCache from '../utils/milestonesCache'; import assigneesCache from '../utils/assigneesCache'; +// Assisted-by: openai-code-assist +const ISSUE_TYPES = ['Bug', 'Feature', 'Task']; + const getContrastColor = (hexColor) => { // Convert hex to RGB const r = parseInt(hexColor.substring(0, 2), 16); @@ -179,6 +183,10 @@ const IssueCard = ({ const [titleDraft, setTitleDraft] = useState(issue.title || ''); const [titleError, setTitleError] = useState(null); const [titleBusy, setTitleBusy] = useState(false); + const [isTypeDialogOpen, setIsTypeDialogOpen] = useState(false); + const [typeDraft, setTypeDraft] = useState(issue.type?.name || 'Feature'); + const [typeError, setTypeError] = useState(null); + const [typeBusy, setTypeBusy] = useState(false); const [isCreateSubIssueOpen, setIsCreateSubIssueOpen] = useState(false); const [commentCount, setCommentCount] = useState(issue.comments || 0); const [descriptionBody, setDescriptionBody] = useState(issue.body || ''); @@ -1004,6 +1012,52 @@ const IssueCard = ({ } }; + // Assisted-by: openai-code-assist + const beginTypeEdit = () => { + if (issue.pull_request) return; + setTypeDraft(issue.type?.name || 'Feature'); + setTypeError(null); + setTypeBusy(false); + setIsTypeDialogOpen(true); + }; + + // Assisted-by: openai-code-assist + const cancelTypeEdit = () => { + setTypeDraft(issue.type?.name || 'Feature'); + setTypeError(null); + setTypeBusy(false); + setIsTypeDialogOpen(false); + }; + + // Assisted-by: openai-code-assist + const handleUpdateType = async () => { + const nextTypeName = String(typeDraft || '').trim(); + if (!nextTypeName) { + setTypeError('Type is required'); + return; + } + if (nextTypeName === issue.type?.name) { + cancelTypeEdit(); + return; + } + + setTypeBusy(true); + setTypeError(null); + try { + const updated = await updateIssueType(issue.number, nextTypeName); + const nextType = updated?.type || { + ...(issue.type || {}), + name: nextTypeName, + }; + setIsTypeDialogOpen(false); + onIssueUpdate?.({ ...issue, ...updated, type: nextType }); + } catch (err) { + setTypeError(err.message); + } finally { + setTypeBusy(false); + } + }; + const handleCommentUpdated = (updatedComment) => { setComments((prev) => prev.map((c) => (c.id === updatedComment.id ? updatedComment : c)) @@ -2395,8 +2449,30 @@ const IssueCard = ({ >
{issue.type && ( - + { + if ( + !issue.pull_request && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault(); + beginTypeEdit(); + } + }} style={{ display: 'inline-block', marginRight: '0.5rem', @@ -2407,13 +2483,31 @@ const IssueCard = ({ backgroundColor: issue.type.color, color: getTypeContrastColor(issue.type.color), whiteSpace: 'nowrap', - cursor: issue.type.description ? 'help' : 'default', + cursor: issue.pull_request + ? issue.type.description + ? 'help' + : 'default' + : 'pointer', }} > {issue.type.name} )} + {!issue.type && !issue.pull_request && ( + + )} {isEditingTitle ? (
+ + Save + , + , + ]} + > +
+ +
+ {ISSUE_TYPES.map((typeName) => ( + + ))} +
+
+ {typeError && ( + + {typeError} + + )} +
+
{ }); }); + it('updates the issue type from the planning view', async () => { + const user = userEvent.setup(); + const onIssueUpdate = vi.fn(); + const issueWithType = { + ...mockIssue, + type: { + name: 'Feature', + color: 'blue', + description: 'A request, idea, or new functionality', + }, + }; + api.updateIssueType.mockResolvedValue({ + type: { + name: 'Task', + color: 'green', + description: 'A unit of work', + }, + }); + + render( + + + + +
+ ); + + await user.click( + screen.getByRole('button', { name: 'Change issue type for #459' }) + ); + await user.click(screen.getByRole('button', { name: 'Task' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(api.updateIssueType).toHaveBeenCalledWith(459, 'Task'); + expect(onIssueUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.objectContaining({ name: 'Task' }), + }) + ); + }); + }); + it('does not show comments control when description is not expanded', async () => { const issueWithComments = { ...mockIssue, comments: 2 }; await act(async () => { diff --git a/frontend/src/components/MarkdownInputModal.jsx b/frontend/src/components/MarkdownInputModal.jsx index 86ce62c..c28f546 100644 --- a/frontend/src/components/MarkdownInputModal.jsx +++ b/frontend/src/components/MarkdownInputModal.jsx @@ -20,7 +20,8 @@ import labelsCache from '../utils/labelsCache'; import assigneesCache from '../utils/assigneesCache'; import UserAvatar from './UserAvatar'; -const ISSUE_TYPES = ['Bug', 'Feature']; +// Assisted-by: openai-code-assist +const ISSUE_TYPES = ['Bug', 'Feature', 'Task']; /** * Popup input window with Edit / Preview markdown tabs. diff --git a/frontend/src/components/MarkdownInputModal.test.jsx b/frontend/src/components/MarkdownInputModal.test.jsx index 3b63ef9..073d739 100644 --- a/frontend/src/components/MarkdownInputModal.test.jsx +++ b/frontend/src/components/MarkdownInputModal.test.jsx @@ -106,6 +106,7 @@ describe('MarkdownInputModal', () => { expect(screen.getByLabelText(/Title/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Bug' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Feature' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Task' })).toBeInTheDocument(); await user.type(screen.getByPlaceholderText('Issue title'), 'My epic'); await user.click(screen.getByRole('button', { name: 'Bug' })); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index cd07423..4429423 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -126,6 +126,21 @@ export const updateIssueTitle = async (issueNumber, title) => { return response.json(); }; +// Assisted-by: openai-code-assist +export const updateIssueType = async (issueNumber, type) => { + const response = await fetch(`${API_BASE}/issues/${issueNumber}/type`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ type }), + }); + if (!response.ok) { + throw new Error(`Failed to update issue type: ${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 261581f..593fba8 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -25,6 +25,7 @@ import { updateComment, updateIssueBody, updateIssueTitle, + updateIssueType, updateLabel, updateMilestoneState, } from './api'; @@ -479,6 +480,19 @@ describe('api', () => { }); }); + it('updateIssueType PATCHes type', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ number: 42, type: { name: 'Task' } }), + }); + await updateIssueType(42, 'Task'); + expect(global.fetch).toHaveBeenCalledWith('/api/v1/issues/42/type', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'Task' }), + }); + }); + it('closeIssue POSTs reason and body', async () => { global.fetch.mockResolvedValue({ ok: true,