diff --git a/README.md b/README.md index 2e698eb..36788f9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ This project provides a backend API (FastAPI) and a frontend web UI for seamless - Automate common GitHub project management tasks - Easy-to-use web interface (frontend) - Powerful RESTful API (backend) -- Robust CLI for scripting and automation --- @@ -24,7 +23,7 @@ This project provides a backend API (FastAPI) and a frontend web UI for seamless ### Prerequisites -- Python 3.14+ +- Python 3.12+ - Node.js 20+ (for the frontend) ### Installation @@ -37,6 +36,11 @@ uv sync --dev # Installs dependencies using uv uv run python -m github_pm # Start the backend API ``` +Backend configuration is through a `.env` file in the `backend` directory. +The .env_sample file shows the necessary configuration keywords, including +a personal access token for GitHub API access and the target GitHub +repository name. + #### Frontend ```bash @@ -75,20 +79,29 @@ and then you can terminate with 'kill-pm' - Navigate to the frontend web UI to manage your GitHub projects visually. - Access the API docs at `http://localhost:8000/docs` when the backend is running. -The UI shows all the milestones defined for the GitHub project, with the description -and due date. You can show all issues and PRs associated with a milestone by opening -the "Show issues" control. +The UI shows all the milestones defined for the configured GitHub project, with +the description and due date. You can show all issues and PRs associated with a +milestone by opening the "Show issues" control. At the top of the page, the Manage Milestones and Manage Labels controls allow you to see all of the available milestones and labels for the project. Here you can delete any item by clicking the "x" in the chiclet, or create new items using the "+" icon. -Each expanded issue is shown with the current milestone: you can assign that issue +Each expanded issue is shown with its current milestone: you can assign that issue to a new milestone using the pulldown. You can also remove assigned labels by clicking the label chiclet's "x" icon, or add new labels to the issue with the "+" icon. +If the issue is assigned to one or more team members, the display will display +their GitHub login names. You can change the list of assignees using the pulldown. + +Issues are listed by default in the order they come from GitHub. The tool allows +you to sort issues under each milestone by a hierarchy of labels, which you can +select and reorder from the "Sort" pulldown at the top of the page. For example, +you can show "bugs" first, or "high priority" followed by "medium priority" +followed by "low priority". + --- ## Development diff --git a/backend/.env_sample b/backend/.env_sample new file mode 100644 index 0000000..518623c --- /dev/null +++ b/backend/.env_sample @@ -0,0 +1,3 @@ +github_token= +github_repo=/ +app_name= diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 2adfce3..2f55601 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -19,7 +19,6 @@ class Connector: - def __init__(self, github_token: str): """Initialize a GitHub connection. @@ -90,8 +89,15 @@ def post( self.response = response return response.json() - def delete(self, path: str, headers: dict[str, str] | None = None) -> dict: - response = self.github.delete(f"{self.base_url}{path}", headers=headers) + def delete( + self, + path: str, + data: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> dict: + response = self.github.delete( + f"{self.base_url}{path}", json=data, headers=headers + ) response.raise_for_status() self.response = response return response.json() if response.content else {} @@ -206,6 +212,61 @@ async def get_issues( return all_issues +@api_router.get("/issue/{issue_number}") +async def get_issue( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], +): + issue = gitctx.get( + f"/repos/{context.github_repo}/issues/{issue_number}", + headers={"Accept": "application/vnd.github.html+json"}, + ) + if "pull_request" not in issue: + query = """query($owner: String!, $repo: String!, $issue: Int!) { + repository(owner: $owner, name: $repo, followRenames: true) { + issue(number: $issue) { + closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { + nodes { + number + title + url + } + } + } + } + } + """ + try: + response = gitctx.post( + "/graphql", + data={ + "query": query, + "variables": { + "owner": gitctx.owner, + "repo": gitctx.repo, + "issue": issue["number"], + }, + }, + ) + data = response["data"] + issue_node = data["repository"]["issue"] + closed = issue_node["closedByPullRequestsReferences"]["nodes"] + if len(closed) > 0: + issue["closed_by"] = [ + { + "number": linked["number"], + "title": linked["title"], + "url": linked["url"], + } + for linked in closed + ] + except Exception as e: + logger.exception( + f"Error finding linked PRs for issue {issue['number']}: {e!r}" + ) + return issue + + @api_router.get("/comments/{issue_number}") async def get_comments( gitctx: Annotated[Connector, Depends(connection)], @@ -243,7 +304,6 @@ async def get_comment_reactions( gitctx: Annotated[Connector, Depends(connection)], comment_id: Annotated[int, Path(title="Comment")], ): - reactions = gitctx.get_paged( f"/repos/{context.github_repo}/issues/comments/{comment_id}/reactions", headers={"Accept": "application/vnd.github.html+json"}, @@ -415,3 +475,49 @@ async def remove_label_from_issue( data={"labels": list(labels)}, ) return issue + + +# Assignee Management + + +@api_router.get("/assignees") +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"}, + ) + return sorted(assignees, key=lambda x: x["login"]) + + +@api_router.post("/issues/{issue_number}/assignees") +async def add_assignee_to_issue( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + assignees: Annotated[list[str], Body(title="Assignees")], +): + # Use PATCH to replace all assignees (GitHub API best practice) + issue = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"assignees": assignees}, + ) + logger.info( + f"Added assignees to issue {issue_number}: {[i['login'] for i in issue['assignees']]}" + ) + return issue + + +@api_router.delete("/issues/{issue_number}/assignees") +async def remove_assignee_from_issue( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + assignees: Annotated[list[str], Body(title="Assignees")], +): + issue = gitctx.delete( + f"/repos/{context.github_repo}/issues/{issue_number}/assignees", + data={"assignees": assignees}, + ) + logger.info( + f"Removed assignees from issue {issue_number}: {[i['login'] for i in issue['assignees']]}" + ) + return issue diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5fb5e94..8100286 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,13 +9,19 @@ import { Bullseye, Button, } from '@patternfly/react-core'; -import { fetchMilestones, fetchProject, fetchLabels } from './services/api'; +import { + fetchMilestones, + fetchProject, + fetchLabels, + fetchAssignees, +} from './services/api'; import MilestoneCard from './components/MilestoneCard'; import ManageMilestones from './components/ManageMilestones'; import ManageLabels from './components/ManageLabels'; import ManageSort from './components/ManageSort'; import milestonesCache from './utils/milestonesCache'; import labelsCache, { clearLabelsCache } from './utils/labelsCache'; +import assigneesCache from './utils/assigneesCache'; import iconImage from './assets/icon.png'; import './icon.css'; @@ -111,6 +117,28 @@ const App = () => { labelsCache.promise = null; }); } + + // Preload assignees + if ( + !assigneesCache.loading && + assigneesCache.data.length === 0 && + !assigneesCache.promise + ) { + assigneesCache.loading = true; + assigneesCache.error = null; + assigneesCache.promise = fetchAssignees() + .then((data) => { + assigneesCache.data = data; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; + }) + .catch((err) => { + assigneesCache.loading = false; + assigneesCache.error = err.message; + assigneesCache.promise = null; + }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index b8c33d8..aa02253 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -1,10 +1,11 @@ // ai-generated: Cursor -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; import App from './App'; import * as api from './services/api'; import { clearMilestonesCache } from './utils/milestonesCache'; import { clearLabelsCache } from './utils/labelsCache'; +import assigneesCache from './utils/assigneesCache'; vi.mock('./services/api'); @@ -13,6 +14,11 @@ describe('App', () => { vi.clearAllMocks(); clearMilestonesCache(); clearLabelsCache(); + // Clear assignees cache + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; // Default mock for fetchProject api.fetchProject.mockResolvedValue({ app_name: 'Test App', @@ -20,12 +26,26 @@ describe('App', () => { }); // Default mock for fetchLabels (preloaded in background) api.fetchLabels.mockResolvedValue([]); + // Default mock for fetchAssignees (preloaded in background) + api.fetchAssignees.mockResolvedValue([]); }); - it('renders loading state initially', () => { + afterEach(() => { + // Clear assignees cache after each test + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; + }); + + it('renders loading state initially', async () => { api.fetchMilestones.mockImplementation(() => new Promise(() => {})); - render(); - expect(screen.getByRole('progressbar')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); }); it('displays project name and repo in title', async () => { @@ -35,7 +55,9 @@ describe('App', () => { }); api.fetchMilestones.mockResolvedValue([]); - render(); + await act(async () => { + render(); + }); await waitFor(() => { expect( @@ -44,19 +66,25 @@ describe('App', () => { }); }); - it('displays loading text while fetching project', () => { + it('displays loading text while fetching project', async () => { api.fetchProject.mockImplementation(() => new Promise(() => {})); api.fetchMilestones.mockResolvedValue([]); - render(); - expect(screen.getByText('Loading...')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); }); it('displays fallback title when project fetch fails', async () => { api.fetchProject.mockRejectedValue(new Error('Failed')); api.fetchMilestones.mockResolvedValue([]); - render(); + await act(async () => { + render(); + }); await waitFor(() => { expect(screen.getByText('GitHub Project Manager')).toBeInTheDocument(); @@ -75,7 +103,9 @@ describe('App', () => { ]; api.fetchMilestones.mockResolvedValue(mockMilestones); - render(); + await act(async () => { + render(); + }); await waitFor(() => { // Milestones appear in both chiclets and cards, so use getAllByText @@ -89,7 +119,9 @@ describe('App', () => { it('renders error message on fetch failure', async () => { api.fetchMilestones.mockRejectedValue(new Error('Network error')); - render(); + await act(async () => { + render(); + }); await waitFor(() => { expect(screen.getByText(/Error loading milestones/i)).toBeInTheDocument(); @@ -99,7 +131,9 @@ describe('App', () => { it('renders empty state when no milestones', async () => { api.fetchMilestones.mockResolvedValue([]); - render(); + await act(async () => { + render(); + }); await waitFor(() => { expect(screen.getByText(/No milestones found/i)).toBeInTheDocument(); diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index 1a37d72..1a4f1be 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -1,5 +1,5 @@ // ai-generated: Cursor -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Card, CardHeader, @@ -28,12 +28,16 @@ import { setIssueMilestone, removeIssueMilestone, fetchIssueReactions, + fetchAssignees, + setIssueAssignees, + removeIssueAssignees, } from '../services/api'; import CommentCard from './CommentCard'; import Reactions from './Reactions'; import UserAvatar from './UserAvatar'; import labelsCache, { clearLabelsCache } from '../utils/labelsCache'; import milestonesCache from '../utils/milestonesCache'; +import assigneesCache from '../utils/assigneesCache'; const getContrastColor = (hexColor) => { // Convert hex to RGB @@ -73,7 +77,7 @@ const getTypeContrastColor = (colorName) => { return darkColors.includes(colorName.toLowerCase()) ? '#ffffff' : '#000000'; }; -const IssueCard = ({ issue, onMilestoneChange }) => { +const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { const daysSince = getDaysSince(issue.created_at); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isCommentsExpanded, setIsCommentsExpanded] = useState(false); @@ -103,6 +107,16 @@ const IssueCard = ({ issue, onMilestoneChange }) => { const [reactions, setReactions] = useState([]); const [reactionsLoading, setReactionsLoading] = useState(false); const [reactionsError, setReactionsError] = useState(null); + const [availableAssignees, setAvailableAssignees] = useState([]); + const [isAssigneesMenuOpen, setIsAssigneesMenuOpen] = useState(false); + const [assigneesLoading, setAssigneesLoading] = useState(false); + const [assigneesError, setAssigneesError] = useState(null); + const [currentAssignees, setCurrentAssignees] = useState( + issue.assignees || [] + ); + const [pendingAssignees, setPendingAssignees] = useState([]); // Temporary selection while dropdown is open + const assigneesMenuRef = useRef(null); + const assigneesToggleRef = useRef(null); useEffect(() => { if ( @@ -148,6 +162,11 @@ const IssueCard = ({ issue, onMilestoneChange }) => { setCurrentMilestone(issue.milestone); }, [issue.milestone]); + // Sync currentAssignees with issue.assignees when issue changes + useEffect(() => { + setCurrentAssignees(Array.isArray(issue.assignees) ? issue.assignees : []); + }, [issue.assignees]); + // Fetch reactions if total_count > 0 useEffect(() => { // Reset reactions when issue changes @@ -182,6 +201,110 @@ const IssueCard = ({ issue, onMilestoneChange }) => { reactionsLoading, ]); + // Preload assignees when component mounts (shared cache) - non-blocking + useEffect(() => { + // If assignees are already cached, use them + if (assigneesCache.data.length > 0) { + setAvailableAssignees(assigneesCache.data); + setAssigneesLoading(false); + setAssigneesError(assigneesCache.error); + return; + } + + // If assignees are currently being fetched, subscribe to the existing promise + if (assigneesCache.promise) { + setAssigneesLoading(true); + assigneesCache.promise + .then(() => { + setAvailableAssignees(assigneesCache.data); + setAssigneesLoading(false); + setAssigneesError(assigneesCache.error); + }) + .catch(() => { + setAssigneesLoading(false); + setAssigneesError(assigneesCache.error); + }); + return; + } + + // Start fetching assignees asynchronously (non-blocking) + if (!assigneesCache.loading) { + assigneesCache.loading = true; + assigneesCache.error = null; + + assigneesCache.promise = fetchAssignees() + .then((data) => { + assigneesCache.data = data; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; + setAvailableAssignees(data); + setAssigneesLoading(false); + setAssigneesError(null); + }) + .catch((err) => { + assigneesCache.loading = false; + assigneesCache.error = err.message; + assigneesCache.promise = null; + setAssigneesLoading(false); + setAssigneesError(err.message); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps - only run once on mount + + // Update loading state when menu opens if assignees are still loading + useEffect(() => { + if (isAssigneesMenuOpen) { + // If assignees are already cached, use them immediately + if (assigneesCache.data.length > 0) { + setAvailableAssignees(assigneesCache.data); + setAssigneesLoading(false); + setAssigneesError(assigneesCache.error); + return; + } + + // If assignees are still loading, show loading state and wait for promise + if (assigneesCache.loading && assigneesCache.promise) { + setAssigneesLoading(true); + setAssigneesError(null); + assigneesCache.promise + .then(() => { + setAvailableAssignees(assigneesCache.data); + setAssigneesLoading(false); + setAssigneesError(assigneesCache.error); + }) + .catch(() => { + setAssigneesLoading(false); + setAssigneesError(assigneesCache.error); + }); + } else if (!assigneesCache.loading && assigneesCache.data.length === 0) { + // Assignees haven't been fetched yet, start fetching now + setAssigneesLoading(true); + assigneesCache.loading = true; + assigneesCache.error = null; + + assigneesCache.promise = fetchAssignees() + .then((data) => { + assigneesCache.data = data; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; + setAvailableAssignees(data); + setAssigneesLoading(false); + setAssigneesError(null); + }) + .catch((err) => { + assigneesCache.loading = false; + assigneesCache.error = err.message; + assigneesCache.promise = null; + setAssigneesLoading(false); + setAssigneesError(err.message); + }); + } + } + }, [isAssigneesMenuOpen]); + // Preload labels when component mounts (shared cache) - non-blocking // useEffect runs after render, so this won't block the initial render useEffect(() => { @@ -289,6 +412,72 @@ const IssueCard = ({ issue, onMilestoneChange }) => { } }, [isLabelMenuOpen]); + // Define handleApplyAssignees before the useEffect that uses it + const handleApplyAssignees = useCallback(async () => { + try { + setAssigneesError(null); + const currentLogins = currentAssignees.map((a) => a.login); + const pendingLogins = pendingAssignees.map((a) => a.login); + + // Check if there are any changes + const currentSet = new Set(currentLogins); + const pendingSet = new Set(pendingLogins); + const hasChanges = + currentLogins.length !== pendingLogins.length || + currentLogins.some((login) => !pendingSet.has(login)) || + pendingLogins.some((login) => !currentSet.has(login)); + + if (!hasChanges) { + // No changes, just close the dropdown + setIsAssigneesMenuOpen(false); + return; + } + + // Determine which assignees were removed and which were added + const removedLogins = currentLogins.filter( + (login) => !pendingSet.has(login) + ); + const addedLogins = pendingLogins.filter( + (login) => !currentSet.has(login) + ); + + let updatedIssue; + if (pendingLogins.length === 0) { + // All assignees removed - use DELETE with all current assignees + if (currentLogins.length > 0) { + updatedIssue = await removeIssueAssignees( + issue.number, + currentLogins + ); + setCurrentAssignees(updatedIssue.assignees || []); + if (onIssueUpdate) { + onIssueUpdate(updatedIssue); + } + } + } else if (removedLogins.length > 0 && addedLogins.length === 0) { + // Only removals, no additions - use DELETE with removed assignees + updatedIssue = await removeIssueAssignees(issue.number, removedLogins); + setCurrentAssignees(updatedIssue.assignees || []); + if (onIssueUpdate) { + onIssueUpdate(updatedIssue); + } + } else { + // Additions or mixed changes - use POST to replace entire list + updatedIssue = await setIssueAssignees(issue.number, pendingLogins); + setCurrentAssignees(updatedIssue.assignees || []); + if (onIssueUpdate) { + onIssueUpdate(updatedIssue); + } + } + setIsAssigneesMenuOpen(false); + } catch (err) { + console.error('Failed to update assignees:', err); + setAssigneesError(err.message); + // On error, reset pending to current + setPendingAssignees([...currentAssignees]); + } + }, [currentAssignees, pendingAssignees, issue.number, onIssueUpdate]); + // Close menu when clicking outside useEffect(() => { const handleClickOutside = (event) => { @@ -311,15 +500,30 @@ const IssueCard = ({ issue, onMilestoneChange }) => { ) { setIsMilestoneMenuOpen(false); } + if ( + isAssigneesMenuOpen && + assigneesToggleRef.current && + !assigneesToggleRef.current.contains(event.target) && + assigneesMenuRef.current && + !assigneesMenuRef.current.contains(event.target) + ) { + // Clicking outside - apply changes before closing + handleApplyAssignees(); + } }; - if (isLabelMenuOpen || isMilestoneMenuOpen) { + if (isLabelMenuOpen || isMilestoneMenuOpen || isAssigneesMenuOpen) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } - }, [isLabelMenuOpen, isMilestoneMenuOpen]); + }, [ + isLabelMenuOpen, + isMilestoneMenuOpen, + isAssigneesMenuOpen, + handleApplyAssignees, + ]); const handleRemoveLabel = async (labelName) => { try { @@ -457,6 +661,50 @@ const IssueCard = ({ issue, onMilestoneChange }) => { return currentLabels.some((label) => label.name === labelName); }; + const isAssigneeSelected = (assigneeLogin) => { + // Use pendingAssignees if dropdown is open, otherwise use currentAssignees + const assigneesToCheck = isAssigneesMenuOpen + ? pendingAssignees + : currentAssignees; + if (!assigneesToCheck || !Array.isArray(assigneesToCheck)) { + return false; + } + return assigneesToCheck.some( + (assignee) => assignee && assignee.login === assigneeLogin + ); + }; + + // Initialize pending assignees when dropdown opens + useEffect(() => { + if (isAssigneesMenuOpen) { + setPendingAssignees([...(currentAssignees || [])]); + setAssigneesError(null); + } + }, [isAssigneesMenuOpen, currentAssignees]); + + const handleToggleAssignee = (assigneeLogin, isChecked) => { + // Only update pending selection, don't make API calls yet + setPendingAssignees((prev) => { + const prevArray = Array.isArray(prev) ? prev : []; + if (isChecked) { + // Add assignee to pending selection + const assigneeToAdd = availableAssignees.find( + (a) => a && a.login === assigneeLogin + ); + if ( + assigneeToAdd && + !prevArray.some((a) => a && a.login === assigneeLogin) + ) { + return [...prevArray, assigneeToAdd]; + } + return prevArray; + } else { + // Remove assignee from pending selection + return prevArray.filter((a) => a && a.login !== assigneeLogin); + } + }); + }; + const getToggleText = () => { const baseText = isDescriptionExpanded ? 'Hide Description' @@ -606,45 +854,201 @@ const IssueCard = ({ issue, onMilestoneChange }) => { )} - {issue.assignees && issue.assignees.length > 0 && ( -
+
- )} + {currentAssignees.length > 0 + ? `Assigned to ${currentAssignees.map((a) => a.login).join(', ')}` + : 'Unassigned'} + + {isAssigneesMenuOpen && ( +
+
+ {assigneesLoading && ( +
+ +
+ Loading assignees... +
+
+ )} + {assigneesError && ( +
+ + {assigneesError} + +
+ )} + {!assigneesLoading && + !assigneesError && + availableAssignees.map((assignee) => { + const isSelected = isAssigneeSelected(assignee.login); + return ( +
{ + // Only toggle if clicking on the row background, not on checkbox, label, or link + const target = e.target; + const isCheckbox = + target.type === 'checkbox' || + target.closest('input[type="checkbox"]'); + const isLabel = target.closest('label'); + const isLink = target.closest('a'); + + if (!isCheckbox && !isLabel && !isLink) { + handleToggleAssignee( + assignee.login, + !isSelected + ); + } + }} + onMouseEnter={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = + '#f0f0f0'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = + 'transparent'; + } + }} + > +
{ + // Stop propagation for the entire checkbox area + e.stopPropagation(); + }} + > + { + // Stop propagation to prevent row click handler + e.stopPropagation(); + // Handle the toggle directly - this ensures it works for both select and deselect + handleToggleAssignee( + assignee.login, + !isSelected + ); + }} + label={ + e.stopPropagation()} + > + + e.stopPropagation()} + > + {assignee.login} + + + } + id={`assignee-${assignee.login}`} + /> +
+
+ ); + })} + {!assigneesLoading && + !assigneesError && + availableAssignees.length === 0 && ( +
+ No assignees available +
+ )} +
+
+ )} +
{ vi.clearAllMocks(); // Mock fetchLabels to return an empty array by default api.fetchLabels.mockResolvedValue([]); + // Mock fetchAssignees to return an empty array by default + api.fetchAssignees.mockResolvedValue([]); + // Clear assignees cache before each test + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; }); - it('renders issue number and title', () => { - render(); - expect(screen.getByText('#459')).toBeInTheDocument(); + afterEach(() => { + // Clear assignees cache after each test + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; + }); + + it('renders issue number and title', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('#459')).toBeInTheDocument(); + }); expect( screen.getByText(/Add support for OpenAI Responses API/) ).toBeInTheDocument(); }); - it('renders issue number as a link', () => { - render(); - const link = screen.getByRole('link', { name: '#459' }); - expect(link).toHaveAttribute('href', mockIssue.html_url); - expect(link).toHaveAttribute('target', '_blank'); - expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + it('renders issue number as a link', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + const link = screen.getByRole('link', { name: '#459' }); + expect(link).toHaveAttribute('href', mockIssue.html_url); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); }); - it('shows description toggle when body exists', () => { - render(); - expect(screen.getByText('Show Description')).toBeInTheDocument(); + it('shows description toggle when body exists', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('Show Description')).toBeInTheDocument(); + }); }); - it('shows comment count in toggle text when comments exist', () => { + it('shows comment count in toggle text when comments exist', async () => { const issueWithComments = { ...mockIssue, comments: 5 }; - render(); - expect( - screen.getByText('Show Description (5 comments)') - ).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect( + screen.getByText('Show Description (5 comments)') + ).toBeInTheDocument(); + }); }); - it('shows singular comment in toggle text for 1 comment', () => { + it('shows singular comment in toggle text for 1 comment', async () => { const issueWithOneComment = { ...mockIssue, comments: 1 }; - render(); - expect( - screen.getByText('Show Description (1 comment)') - ).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect( + screen.getByText('Show Description (1 comment)') + ).toBeInTheDocument(); + }); }); - it('does not show comment count when comments is 0', () => { + it('does not show comment count when comments is 0', async () => { const issueWithNoComments = { ...mockIssue, comments: 0 }; - render(); - expect(screen.getByText('Show Description')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('Show Description')).toBeInTheDocument(); + }); expect(screen.queryByText(/comment/)).not.toBeInTheDocument(); }); @@ -110,52 +150,84 @@ describe('IssueCard', () => { }); }); - it('renders user information', () => { - render(); - expect(screen.getByText('tosokin')).toBeInTheDocument(); + it('renders user information', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('tosokin')).toBeInTheDocument(); + }); }); - it('renders user avatar', () => { - render(); - const avatar = screen.getByAltText('tosokin'); - expect(avatar).toHaveAttribute('src', mockIssue.user.avatar_url); + it('renders user avatar', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + const avatar = screen.getByAltText('tosokin'); + expect(avatar).toHaveAttribute('src', mockIssue.user.avatar_url); + }); }); - it('renders created date with days since', () => { - render(); - expect(screen.getByText(/\(/)).toBeInTheDocument(); // Should contain days ago + it('renders created date with days since', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText(/\(/)).toBeInTheDocument(); // Should contain days ago + }); }); - it('handles missing body gracefully', () => { + it('handles missing body gracefully', async () => { const issueWithoutBody = { ...mockIssue, body: null, body_html: null }; - render(); - expect(screen.getByText('#459')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('#459')).toBeInTheDocument(); + }); }); - it('handles missing user gracefully', () => { + it('handles missing user gracefully', async () => { const issueWithoutUser = { ...mockIssue, user: null }; - render(); - expect(screen.getByText('Unknown')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); }); - it('renders labels when present', () => { - render(); - expect(screen.getByText('enhancement')).toBeInTheDocument(); + it('renders labels when present', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('enhancement')).toBeInTheDocument(); + }); }); - it('handles missing labels gracefully', () => { + it('handles missing labels gracefully', async () => { const issueWithoutLabels = { ...mockIssue, labels: null }; - render(); - expect(screen.getByText('#459')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('#459')).toBeInTheDocument(); + }); }); - it('handles empty labels array', () => { + it('handles empty labels array', async () => { const issueWithEmptyLabels = { ...mockIssue, labels: [] }; - render(); - expect(screen.getByText('#459')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('#459')).toBeInTheDocument(); + }); }); - it('renders type chiclet when type is present', () => { + it('renders type chiclet when type is present', async () => { const issueWithType = { ...mockIssue, type: { @@ -164,17 +236,25 @@ describe('IssueCard', () => { description: 'A request, idea, or new functionality', }, }; - render(); - expect(screen.getByText('Feature')).toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('Feature')).toBeInTheDocument(); + }); }); - it('does not render type chiclet when type is null', () => { + it('does not render type chiclet when type is null', async () => { const issueWithoutType = { ...mockIssue, type: null }; - render(); - expect(screen.queryByText('Feature')).not.toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.queryByText('Feature')).not.toBeInTheDocument(); + }); }); - it('renders type chiclet with correct name and styling', () => { + it('renders type chiclet with correct name and styling', async () => { const issueWithType = { ...mockIssue, type: { @@ -183,31 +263,43 @@ describe('IssueCard', () => { description: 'A request, idea, or new functionality', }, }; - render(); - const typeChiclet = screen.getByText('Feature'); - // Verify the type chiclet is present - expect(typeChiclet).toBeInTheDocument(); - // Verify it's wrapped in a span (the chiclet container) - const typeSpan = typeChiclet.closest('span'); - expect(typeSpan).toBeInTheDocument(); - // Verify the span has a background color style (exact format may vary) - expect(typeSpan).toHaveAttribute('style'); - expect(typeSpan?.getAttribute('style')).toContain('blue'); + await act(async () => { + render(); + }); + await waitFor(() => { + const typeChiclet = screen.getByText('Feature'); + // Verify the type chiclet is present + expect(typeChiclet).toBeInTheDocument(); + // Verify it's wrapped in a span (the chiclet container) + const typeSpan = typeChiclet.closest('span'); + expect(typeSpan).toBeInTheDocument(); + // Verify the span has a background color style (exact format may vary) + expect(typeSpan).toHaveAttribute('style'); + expect(typeSpan?.getAttribute('style')).toContain('blue'); + }); }); - it('does not show comments control when description is not expanded', () => { + it('does not show comments control when description is not expanded', async () => { const issueWithComments = { ...mockIssue, comments: 2 }; - render(); - expect(screen.queryByText(/Show Comments/i)).not.toBeInTheDocument(); + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.queryByText(/Show Comments/i)).not.toBeInTheDocument(); + }); }); it('shows comments expansion control only when description is expanded', async () => { const user = userEvent.setup(); const issueWithComments = { ...mockIssue, comments: 2 }; - render(); + await act(async () => { + render(); + }); // Comments control should not be visible initially - expect(screen.queryByText(/Show Comments/i)).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText(/Show Comments/i)).not.toBeInTheDocument(); + }); // Expand description const descriptionToggle = screen.getByText('Show Description (2 comments)'); @@ -223,7 +315,9 @@ describe('IssueCard', () => { const user = userEvent.setup(); api.fetchComments.mockResolvedValue([]); const issueWithComments = { ...mockIssue, comments: 1 }; - render(); + await act(async () => { + render(); + }); // Expand description const descriptionToggle = screen.getByText('Show Description (1 comment)'); @@ -263,7 +357,9 @@ describe('IssueCard', () => { api.fetchComments.mockResolvedValue(mockComments); const issueWithComments = { ...mockIssue, comments: 1 }; - render(); + await act(async () => { + render(); + }); // First expand description const descriptionToggle = screen.getByText('Show Description (1 comment)'); diff --git a/frontend/src/components/MilestoneCard.jsx b/frontend/src/components/MilestoneCard.jsx index 05566e1..2a2af3f 100644 --- a/frontend/src/components/MilestoneCard.jsx +++ b/frontend/src/components/MilestoneCard.jsx @@ -157,6 +157,14 @@ const MilestoneCard = ({ milestone, sortOrder = [] }) => { }); } }} + onIssueUpdate={(updatedIssue) => { + // Update the issue in the issues array + setIssues((prevIssues) => + prevIssues.map((i) => + i.id === updatedIssue.id ? updatedIssue : i + ) + ); + }} /> ))}
diff --git a/frontend/src/components/MilestoneCard.test.jsx b/frontend/src/components/MilestoneCard.test.jsx index bb428eb..b486389 100644 --- a/frontend/src/components/MilestoneCard.test.jsx +++ b/frontend/src/components/MilestoneCard.test.jsx @@ -1,9 +1,10 @@ // ai-generated: Cursor -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MilestoneCard from './MilestoneCard'; import * as api from '../services/api'; +import assigneesCache from '../utils/assigneesCache'; vi.mock('../services/api'); @@ -18,21 +19,47 @@ describe('MilestoneCard', () => { beforeEach(() => { vi.clearAllMocks(); api.fetchLabels.mockResolvedValue([]); + api.fetchAssignees.mockResolvedValue([]); + // Clear assignees cache before each test + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; }); - it('renders milestone title', () => { - render(); - expect(screen.getByText('v0.6.0')).toBeInTheDocument(); + afterEach(() => { + // Clear assignees cache after each test + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; }); - it('renders milestone description when provided', () => { - render(); - expect(screen.getByText('Version 0.6.0')).toBeInTheDocument(); + it('renders milestone title', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('v0.6.0')).toBeInTheDocument(); + }); }); - it('renders due date when provided', () => { - render(); - expect(screen.getByText(/Due:/i)).toBeInTheDocument(); + it('renders milestone description when provided', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText('Version 0.6.0')).toBeInTheDocument(); + }); + }); + + it('renders due date when provided', async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + expect(screen.getByText(/Due:/i)).toBeInTheDocument(); + }); }); it('expands and fetches issues when clicked', async () => { @@ -46,11 +73,16 @@ describe('MilestoneCard', () => { html_url: 'https://github.com/test/issue/459', user: { login: 'testuser', avatar_url: 'https://avatar.url' }, created_at: '2025-01-01T00:00:00Z', + labels: [], + comments: 0, }, ]; api.fetchIssues.mockResolvedValue(mockIssues); - render(); + await act(async () => { + render(); + }); + const expandButton = screen.getByRole('button', { name: /show issues/i }); await user.click(expandButton); @@ -58,16 +90,21 @@ describe('MilestoneCard', () => { expect(api.fetchIssues).toHaveBeenCalledWith(6, []); }); - await waitFor(() => { - expect(screen.getByText(/Test Issue/)).toBeInTheDocument(); - }); + await waitFor( + () => { + expect(screen.getByText(/Test Issue/)).toBeInTheDocument(); + }, + { timeout: 3000 } + ); }); it('shows loading spinner while fetching issues', async () => { const user = userEvent.setup(); api.fetchIssues.mockImplementation(() => new Promise(() => {})); - render(); + await act(async () => { + render(); + }); const expandButton = screen.getByRole('button', { name: /show issues/i }); await user.click(expandButton); @@ -80,7 +117,9 @@ describe('MilestoneCard', () => { const user = userEvent.setup(); api.fetchIssues.mockRejectedValue(new Error('Fetch failed')); - render(); + await act(async () => { + render(); + }); const expandButton = screen.getByRole('button', { name: /show issues/i }); await user.click(expandButton); @@ -93,7 +132,9 @@ describe('MilestoneCard', () => { const user = userEvent.setup(); api.fetchIssues.mockResolvedValue([]); - render(); + await act(async () => { + render(); + }); const expandButton = screen.getByRole('button', { name: /show issues/i }); await user.click(expandButton); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 7aebf70..992bed6 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -163,3 +163,39 @@ export const fetchCommentReactions = async (commentId) => { } return response.json(); }; + +export const fetchAssignees = async () => { + const response = await fetch(`${API_BASE}/assignees`); + if (!response.ok) { + throw new Error(`Failed to fetch assignees: ${response.statusText}`); + } + return response.json(); +}; + +export const setIssueAssignees = async (issueNumber, assignees) => { + const response = await fetch(`${API_BASE}/issues/${issueNumber}/assignees`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(assignees), + }); + if (!response.ok) { + throw new Error(`Failed to set assignees: ${response.statusText}`); + } + return response.json(); +}; + +export const removeIssueAssignees = async (issueNumber, assignees) => { + const response = await fetch(`${API_BASE}/issues/${issueNumber}/assignees`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(assignees), + }); + if (!response.ok) { + throw new Error(`Failed to remove assignees: ${response.statusText}`); + } + return response.json(); +}; diff --git a/frontend/src/utils/assigneesCache.js b/frontend/src/utils/assigneesCache.js new file mode 100644 index 0000000..10fba16 --- /dev/null +++ b/frontend/src/utils/assigneesCache.js @@ -0,0 +1,19 @@ +// ai-generated: Cursor +// Shared assignees cache - loaded once and shared across all components +let assigneesCache = { + data: [], + loading: false, + error: null, + promise: null, +}; + +export const getAssigneesCache = () => assigneesCache; + +export const clearAssigneesCache = () => { + assigneesCache.data = []; + assigneesCache.loading = false; + assigneesCache.error = null; + assigneesCache.promise = null; +}; + +export default assigneesCache;