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 = ({ >