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
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@ 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

---

## Getting Started

### Prerequisites

- Python 3.14+
- Python 3.12+
- Node.js 20+ (for the frontend)

### Installation
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/.env_sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
github_token=<personal access token>
github_repo=<organization>/<repository>
app_name=<GitHub Planner for project>
114 changes: 110 additions & 4 deletions backend/src/github_pm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@


class Connector:

def __init__(self, github_token: str):
"""Initialize a GitHub connection.

Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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
30 changes: 29 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
}, []);

Expand Down
60 changes: 47 additions & 13 deletions frontend/src/App.test.jsx
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -13,19 +14,38 @@ 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',
github_repo: 'test/repo',
});
// 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(<App />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
await act(async () => {
render(<App />);
});
await waitFor(() => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});

it('displays project name and repo in title', async () => {
Expand All @@ -35,7 +55,9 @@ describe('App', () => {
});
api.fetchMilestones.mockResolvedValue([]);

render(<App />);
await act(async () => {
render(<App />);
});

await waitFor(() => {
expect(
Expand All @@ -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(<App />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await act(async () => {
render(<App />);
});
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(<App />);
await act(async () => {
render(<App />);
});

await waitFor(() => {
expect(screen.getByText('GitHub Project Manager')).toBeInTheDocument();
Expand All @@ -75,7 +103,9 @@ describe('App', () => {
];
api.fetchMilestones.mockResolvedValue(mockMilestones);

render(<App />);
await act(async () => {
render(<App />);
});

await waitFor(() => {
// Milestones appear in both chiclets and cards, so use getAllByText
Expand All @@ -89,7 +119,9 @@ describe('App', () => {
it('renders error message on fetch failure', async () => {
api.fetchMilestones.mockRejectedValue(new Error('Network error'));

render(<App />);
await act(async () => {
render(<App />);
});

await waitFor(() => {
expect(screen.getByText(/Error loading milestones/i)).toBeInTheDocument();
Expand All @@ -99,7 +131,9 @@ describe('App', () => {
it('renders empty state when no milestones', async () => {
api.fetchMilestones.mockResolvedValue([]);

render(<App />);
await act(async () => {
render(<App />);
});

await waitFor(() => {
expect(screen.getByText(/No milestones found/i)).toBeInTheDocument();
Expand Down
Loading