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

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

Expand Down
148 changes: 146 additions & 2 deletions frontend/src/components/IssueCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
renderMarkdown,
updateIssueBody,
updateIssueTitle,
updateIssueType,
addBlockedBy,
removeBlockedBy,
addBlocking,
Expand All @@ -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);
Expand Down Expand Up @@ -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 || '');
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -2395,8 +2449,30 @@ const IssueCard = ({
>
<div style={{ minWidth: 0, flex: 1 }}>
{issue.type && (
<Tooltip content={issue.type.description || ''}>
<Tooltip
content={
issue.type.description ||
(!issue.pull_request ? 'Change issue type' : '')
}
>
<span
role={issue.pull_request ? undefined : 'button'}
tabIndex={issue.pull_request ? undefined : 0}
aria-label={
issue.pull_request
? undefined
: `Change issue type for #${issue.number}`
}
onClick={beginTypeEdit}
onKeyDown={(event) => {
if (
!issue.pull_request &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault();
beginTypeEdit();
}
}}
style={{
display: 'inline-block',
marginRight: '0.5rem',
Expand All @@ -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}
</span>
</Tooltip>
)}
{!issue.type && !issue.pull_request && (
<Button
variant="secondary"
onClick={beginTypeEdit}
aria-label={`Set issue type for #${issue.number}`}
style={{
marginRight: '0.5rem',
padding: '0.125rem 0.5rem',
fontSize: '0.75rem',
}}
>
Set type
</Button>
)}
{isEditingTitle ? (
<div
style={{
Expand Down Expand Up @@ -2858,6 +2952,56 @@ const IssueCard = ({
submitLabel="Create Issue"
onSubmit={handleCreateSubIssue}
/>
<Modal
title={`Change type for #${issue.number}`}
isOpen={isTypeDialogOpen}
onClose={cancelTypeEdit}
actions={[
<Button
key="save"
variant="primary"
onClick={handleUpdateType}
isLoading={typeBusy}
isDisabled={typeBusy}
>
Save
</Button>,
<Button
key="cancel"
variant="link"
onClick={cancelTypeEdit}
isDisabled={typeBusy}
>
Cancel
</Button>,
]}
>
<Form>
<FormGroup label="Type" fieldId={`issue-type-${issue.number}`}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{ISSUE_TYPES.map((typeName) => (
<Button
key={typeName}
variant={typeDraft === typeName ? 'primary' : 'secondary'}
onClick={() => setTypeDraft(typeName)}
isDisabled={typeBusy}
style={{
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
}}
>
{typeName}
</Button>
))}
</div>
</FormGroup>
{typeError && (
<Alert variant="danger" title="Unable to update type" isInline>
{typeError}
</Alert>
)}
</Form>
</Modal>
<Modal
title="Create New Label"
isOpen={isCreateLabelDialogOpen}
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/IssueCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,49 @@ describe('IssueCard', () => {
});
});

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

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 () => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/MarkdownInputModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/MarkdownInputModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading
Loading