diff --git a/AGENTS.md b/AGENTS.md index 21e3a86..b3c88ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,54 @@ # Agent instructions for github_pm workspaces +## Read this file when starting or resuming work + +- **Open `AGENTS.md` again** when you begin a task on this repo or return after a long gap, so required checks (below) stay in context until **`tox`** and frontend checks are green. + ## Required checks before finishing any task -- **`tox` must complete successfully** for every change that touches the Python backend (and should be run once you believe backend work is done). Run it from the **`backend`** directory: +### Python backend (`backend/`) + +- **`tox` must complete successfully** for every change that touches the Python backend (and must be run once you believe backend work is done): ```bash cd backend && tox ``` - This runs the environments defined in `backend/pyproject.toml` (format, import order, lint, tests, coverage). Do **not** consider backend work complete while **`tox`** reports failures. + This runs the environments defined in `backend/pyproject.toml`: **Black** (`format`), **isort** (`isort`), **flake8** (`lint`), **pytest** (`test`), and **coverage** (`coverage`). Do **not** consider backend work complete while **`tox`** reports failures. + +- **While iterating**, you may run a faster subset (still required before hand-off if you only used this shortcut): + + ```bash + cd backend && tox -e format,isort,lint + ``` + + When that is green, run the full **`tox`** (including **`test`** / **`coverage`**) before stopping. + +- **Auto-fixing style** (use only when you are already touching those files; avoid unrelated reformatting): from `backend/` with dev dependencies installed: + + ```bash + uv sync --extra dev + uv run black src tests + uv run isort src tests + ``` + + Then re-run **`tox`** (or at least **`tox -e format,isort,lint`**) so checks pass without relying on uncommitted formatter drift. + +- **flake8** enforces more than imports: for example **E731** forbids assigning a **`lambda`** where a nested **`def`** is clearer. Fix all **flake8** issues, not only import order. + +- **isort** is configured in `pyproject.toml` (`profile = "black"`, `known_first_party = ["github_pm"]`). First-party imports must match that layout (including ordering among `github_pm.*` imports). + +- **Fix all failures** those tools report **before** stopping. A green full **`tox`** run is the acceptance bar for backend changes. + +### Frontend (`frontend/`) + +When the task changes UI or client code under `frontend/src/`: -- **Fix all lint failures and unit test failures** reported by those checks (and any other checks you ran) **before** stopping. A green **`tox`** run is the acceptance bar for backend changes. +```bash +cd frontend && npm run format && npm run format:check && npm test +``` -- For **frontend** (`frontend/`) changes, run **`npm test`** (and **`npm run format:check`** if you edited formatted sources) from `frontend/` and fix failures there as well when the task involves the UI or client code. +- **`npm run format`** applies Prettier; **`npm run format:check`** verifies formatting in CI style; **`npm test`** runs the Vitest suite. Do not skip **`format:check`** after editing formatted sources. ## Notes diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 379df46..206852a 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -3,6 +3,7 @@ import re import time from typing import Annotated, Any, AsyncGenerator +from urllib.parse import quote_plus from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from pydantic import BaseModel, Field @@ -71,6 +72,30 @@ def get_paged(self, path: str, headers: dict[str, str] | None = None) -> list[di break return results + def search_issue_items( + self, search_query: str, headers: dict[str, str] | None = None + ) -> list[dict]: + """Run ``GET /search/issues`` with pagination; returns the ``items`` array union.""" + q_param = quote_plus(search_query) + url: str | None = f"{self.base_url}/search/issues?q={q_param}&per_page=100" + results: list[dict] = [] + while url: + response = self.github.get(url, headers=headers) + response.raise_for_status() + data = response.json() + items = data.get("items") + if isinstance(items, list): + results.extend(items) + url = None + link_header = response.headers.get("link") + if link_header: + for link in link_header.split(","): + if 'rel="next"' in link: + url = link.split(";")[0].strip().strip("<>") + logger.debug("search/issues paging to: %s", url) + break + return results + def patch( self, path: str, data: dict[str, Any], headers: dict[str, str] | None = None ) -> dict: diff --git a/backend/src/github_pm/app.py b/backend/src/github_pm/app.py index e9c9c19..041d601 100644 --- a/backend/src/github_pm/app.py +++ b/backend/src/github_pm/app.py @@ -2,6 +2,7 @@ from github_pm.api import api_router from github_pm.sdlc_api import sdlc_router +from github_pm.status_report_api import status_report_router router = APIRouter() @@ -13,6 +14,7 @@ async def health(): router.include_router(api_router, prefix="/api/v1") router.include_router(sdlc_router, prefix="/api/v1") +router.include_router(status_report_router, prefix="/api/v1") app = FastAPI( title="GitHub Project Management API", diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index 1df1571..3268271 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable, Mapping, Sequence -from datetime import datetime, timedelta, UTC +from datetime import date, datetime, timedelta, UTC import re from typing import Any, Literal from urllib.parse import quote_plus @@ -340,6 +340,7 @@ def graphql_search_pull_requests( search_query: str, *, page_size: int = 100, + filter_bot_authors: bool = True, ) -> list[dict[str, Any]]: """Paginate GitHub GraphQL search (PullRequest nodes).""" nodes: list[dict[str, Any]] = [] @@ -351,6 +352,8 @@ def graphql_search_pull_requests( nodes { ... on PullRequest { number + title + url createdAt mergedAt additions @@ -384,7 +387,71 @@ def graphql_search_pull_requests( raise RuntimeError(f"GitHub GraphQL error: {errors!r}") search = data.get("data", {}).get("search") or {} batch = search.get("nodes") or [] - nodes.extend(filter_out_bot_pr_nodes(batch)) + if filter_bot_authors: + nodes.extend(filter_out_bot_pr_nodes(batch)) + else: + nodes.extend([n for n in batch if n and n.get("number") is not None]) + page = search.get("pageInfo") or {} + if not page.get("hasNextPage"): + break + cursor = page.get("endCursor") + if not cursor: + break + return nodes + + +def graphql_search_timeline_nodes( + post_graphql: Callable[[dict[str, Any]], dict[str, Any]], + search_query: str, + *, + page_size: int = 100, +) -> list[dict[str, Any]]: + """Paginate GraphQL ``search(type: ISSUE)`` returning Issue and PullRequest nodes. + + Used when REST ``GET /search/issues`` rejects the same query string (422). Request only + fields common to both types. + """ + nodes: list[dict[str, Any]] = [] + cursor: str | None = None + gql = """ + query($q: String!, $first: Int!, $after: String) { + search(query: $q, type: ISSUE, first: $first, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on PullRequest { + number + title + url + createdAt + } + ... on Issue { + number + title + url + createdAt + } + } + } + } + """ + while True: + payload = { + "query": gql, + "variables": { + "q": search_query, + "first": page_size, + "after": cursor, + }, + } + data = post_graphql(payload) + errors = data.get("errors") + if errors: + logger.error("GraphQL errors: %s", errors) + raise RuntimeError(f"GitHub GraphQL error: {errors!r}") + search = data.get("data", {}).get("search") or {} + batch = search.get("nodes") or [] + nodes.extend([n for n in batch if n and n.get("number") is not None]) page = search.get("pageInfo") or {} if not page.get("hasNextPage"): break @@ -405,6 +472,34 @@ def merged_prs_query(github_repo: str, merged_since: datetime) -> str: ) +def merged_prs_query_between(github_repo: str, start_d: date, end_d: date) -> str: + """GraphQL issue search: merged PRs with merge date in ``[start_d, end_d]`` (UTC calendar days). + + REST ``GET /search/issues`` rejects several merged/closed date combinations (422); GraphQL + ``search`` accepts ``merged:`` ranges the same way as the web UI. + """ + a, b = start_d.isoformat(), end_d.isoformat() + if a > b: + a, b = b, a + return f"{repo_search_fragment(github_repo)} is:pr is:merged merged:{a}..{b}" + + +def opened_prs_between_query(github_repo: str, start_d: date, end_d: date) -> str: + """PRs with ``created`` in ``[start_d, end_d]`` (UTC calendar days, inclusive).""" + a, b = start_d.isoformat(), end_d.isoformat() + if a > b: + a, b = b, a + return f"{repo_search_fragment(github_repo)} is:pr created:{a}..{b}" + + +def opened_issues_between_query(github_repo: str, start_d: date, end_d: date) -> str: + """Issues (not PRs) with ``created`` in ``[start_d, end_d]`` (UTC calendar days, inclusive).""" + a, b = start_d.isoformat(), end_d.isoformat() + if a > b: + a, b = b, a + return f"{repo_search_fragment(github_repo)} is:issue created:{a}..{b}" + + def opened_prs_query(github_repo: str, created_since: datetime) -> str: return ( f"{repo_search_fragment(github_repo)} is:pr " diff --git a/backend/src/github_pm/status_report_api.py b/backend/src/github_pm/status_report_api.py new file mode 100644 index 0000000..96bfc26 --- /dev/null +++ b/backend/src/github_pm/status_report_api.py @@ -0,0 +1,37 @@ +"""REST API for the weekly project status report.""" + +from __future__ import annotations + +from datetime import date, datetime, UTC +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from github_pm.api import connection, Connector +from github_pm.status_report_models import ProjectStatusReportResponse +from github_pm.status_report_service import build_project_status_report + +status_report_router = APIRouter(tags=["project-status"]) + + +def _default_end_date() -> date: + return datetime.now(UTC).date() + + +@status_report_router.get("/project-status", response_model=ProjectStatusReportResponse) +async def get_project_status_report( + gitctx: Annotated[Connector, Depends(connection)], + end_date: Annotated[ + date | None, + Query( + description="Last day of the 7-day window (UTC calendar date). Defaults to today in UTC.", + ), + ] = None, +): + """ + Status for seven **calendar** days inclusive: ``end_date - 6 days`` through ``end_date``. + + Sections: merged pull requests (by merge date), pull requests opened, issues opened (PRs excluded). + """ + resolved_end = end_date if end_date is not None else _default_end_date() + return build_project_status_report(gitctx, end_date=resolved_end) diff --git a/backend/src/github_pm/status_report_models.py b/backend/src/github_pm/status_report_models.py new file mode 100644 index 0000000..558aed3 --- /dev/null +++ b/backend/src/github_pm/status_report_models.py @@ -0,0 +1,34 @@ +"""Pydantic models for the weekly project status report API.""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, Field + + +class StatusReportItem(BaseModel): + """A GitHub issue or pull request row for the status UI.""" + + number: int = Field(description="Issue or PR number") + title: str = Field(description="Title") + html_url: str = Field(description="GitHub HTML URL for the issue or PR") + + +class ProjectStatusReportResponse(BaseModel): + """Seven calendar days inclusive ending on ``end_date`` (UTC calendar dates).""" + + start_date: date = Field(description="First calendar day of the window (inclusive)") + end_date: date = Field(description="Last calendar day of the window (inclusive)") + merged_pull_requests: list[StatusReportItem] = Field( + default_factory=list, + description="Pull requests merged in the window (by merge date)", + ) + opened_pull_requests: list[StatusReportItem] = Field( + default_factory=list, + description="Pull requests created in the window", + ) + opened_issues: list[StatusReportItem] = Field( + default_factory=list, + description="Issues created in the window (pull requests excluded)", + ) diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py new file mode 100644 index 0000000..8037e83 --- /dev/null +++ b/backend/src/github_pm/status_report_service.py @@ -0,0 +1,108 @@ +"""Weekly project status via GraphQL ``search`` (REST ``/search/issues`` returns 422 for these queries).""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, UTC +from typing import Any + +from github_pm import sdlc_metrics as sm +from github_pm.api import Connector +from github_pm.status_report_models import ( + ProjectStatusReportResponse, + StatusReportItem, +) + + +def _item_from_gql_node(node: dict[str, Any]) -> StatusReportItem: + return StatusReportItem( + number=int(node["number"]), + title=str(node.get("title") or ""), + html_url=str(node.get("url") or ""), + ) + + +def _merged_at_sort_key(node: dict[str, Any]) -> datetime: + m = sm.parse_github_ts(node.get("mergedAt")) + if not m: + return datetime.min.replace(tzinfo=UTC) + return m + + +def _created_at_sort_key(node: dict[str, Any]) -> datetime: + c = sm.parse_github_ts(node.get("createdAt")) + if not c: + return datetime.min.replace(tzinfo=UTC) + return c + + +def _merged_calendar_in_window( + node: dict[str, Any], start_d: date, end_d: date +) -> bool: + m = sm.parse_github_ts(node.get("mergedAt")) + if not m: + return False + md = m.astimezone(UTC).date() + return start_d <= md <= end_d + + +def _created_calendar_in_window( + node: dict[str, Any], start_d: date, end_d: date +) -> bool: + c = sm.parse_github_ts(node.get("createdAt")) + if not c: + return False + cd = c.astimezone(UTC).date() + return start_d <= cd <= end_d + + +def build_project_status_report( + gitctx: Connector, + *, + end_date: date, +) -> ProjectStatusReportResponse: + """Build the report for seven calendar days ending on ``end_date`` (inclusive).""" + start_date = end_date - timedelta(days=6) + repo = f"{gitctx.owner}/{gitctx.repo}" + + def post_gql(payload: dict[str, Any]) -> dict[str, Any]: + return gitctx.post("/graphql", payload) + + merged_q = sm.merged_prs_query_between(repo, start_date, end_date) + merged_nodes = sm.graphql_search_pull_requests( + post_gql, + merged_q, + filter_bot_authors=False, + ) + merged_in_window = [ + n for n in merged_nodes if _merged_calendar_in_window(n, start_date, end_date) + ] + merged_in_window.sort(key=_merged_at_sort_key, reverse=True) + merged_items = [_item_from_gql_node(n) for n in merged_in_window] + + opened_pr_q = sm.opened_prs_between_query(repo, start_date, end_date) + opened_pr_nodes = sm.graphql_search_timeline_nodes(post_gql, opened_pr_q) + opened_pr_filtered = [ + n + for n in opened_pr_nodes + if n.get("__typename") == "PullRequest" + and _created_calendar_in_window(n, start_date, end_date) + ] + opened_pr_filtered.sort(key=_created_at_sort_key, reverse=True) + + opened_issue_q = sm.opened_issues_between_query(repo, start_date, end_date) + opened_issue_nodes = sm.graphql_search_timeline_nodes(post_gql, opened_issue_q) + opened_issue_filtered = [ + n + for n in opened_issue_nodes + if n.get("__typename") == "Issue" + and _created_calendar_in_window(n, start_date, end_date) + ] + opened_issue_filtered.sort(key=_created_at_sort_key, reverse=True) + + return ProjectStatusReportResponse( + start_date=start_date, + end_date=end_date, + merged_pull_requests=merged_items, + opened_pull_requests=[_item_from_gql_node(n) for n in opened_pr_filtered], + opened_issues=[_item_from_gql_node(n) for n in opened_issue_filtered], + ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ea90879..137bab6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1206,3 +1206,53 @@ def test_get_project_endpoint(self): data = response.json() assert "app_name" in data assert "github_repo" in data + + +class TestSearchIssueItems: + """Tests for ``Connector.search_issue_items`` (GitHub search pagination).""" + + def test_single_page(self): + mock_session = Mock() + resp = Mock() + resp.raise_for_status = Mock() + resp.json.return_value = {"items": [{"number": 7}], "total_count": 1} + resp.headers.get.return_value = None + mock_session.get.return_value = resp + + with ( + patch("github_pm.api.requests.session", return_value=mock_session), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "o/r" + mock_context.github_token = "tok" + conn = Connector("tok", github_repo="o/r") + items = conn.search_issue_items("repo:o/r+is:issue") + + assert items == [{"number": 7}] + mock_session.get.assert_called_once() + + def test_follows_next_link(self): + mock_session = Mock() + first = Mock() + first.raise_for_status = Mock() + first.json.return_value = {"items": [{"number": 1}], "total_count": 2} + first.headers.get.return_value = ( + '; rel="next"' + ) + second = Mock() + second.raise_for_status = Mock() + second.json.return_value = {"items": [{"number": 2}], "total_count": 2} + second.headers.get.return_value = None + mock_session.get.side_effect = [first, second] + + with ( + patch("github_pm.api.requests.session", return_value=mock_session), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "o/r" + mock_context.github_token = "tok" + conn = Connector("tok", github_repo="o/r") + items = conn.search_issue_items("repo:o/r+is:issue") + + assert items == [{"number": 1}, {"number": 2}] + assert mock_session.get.call_count == 2 diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py new file mode 100644 index 0000000..e7e5419 --- /dev/null +++ b/backend/tests/test_status_report_api.py @@ -0,0 +1,146 @@ +"""Tests for project status report API (mocked GitHub GraphQL).""" + +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient +import pytest + +from github_pm.api import connection +from github_pm.app import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def mock_connector_graphql(): + gitctx = MagicMock() + gitctx.owner = "test" + gitctx.repo = "repo" + + merged_nodes = [ + { + "number": 10, + "title": "Merged PR", + "url": "https://github.com/test/repo/pull/10", + "mergedAt": "2025-04-06T10:00:00Z", + "createdAt": "2025-04-01T10:00:00Z", + "additions": 1, + "deletions": 1, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + } + ] + + opened_pr_nodes = [ + { + "__typename": "PullRequest", + "number": 11, + "title": "Opened PR", + "url": "https://github.com/test/repo/pull/11", + "createdAt": "2025-04-05T12:00:00Z", + } + ] + + issue_nodes = [ + { + "__typename": "Issue", + "number": 12, + "title": "New issue", + "url": "https://github.com/test/repo/issues/12", + "createdAt": "2025-04-04T12:00:00Z", + } + ] + + def post_side(path: str, data=None, **kwargs): + body = data + if path != "/graphql" or not isinstance(body, dict): + raise AssertionError(f"unexpected post {path=!r} body={body!r}") + q = (body.get("variables") or {}).get("q") or "" + if "is:merged" in q and "merged:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": merged_nodes, + } + } + } + if "is:issue" in q and "created:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": issue_nodes, + } + } + } + if "is:pr" in q and "created:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": opened_pr_nodes, + } + } + } + raise AssertionError(f"unexpected graphql q={q!r}") + + gitctx.post.side_effect = post_side + return gitctx + + +class TestProjectStatusReport: + def test_report_ok_with_end_date(self, client, mock_connector_graphql): + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get("/api/v1/project-status", params={"end_date": "2025-04-10"}) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + body = r.json() + assert body["start_date"] == "2025-04-04" + assert body["end_date"] == "2025-04-10" + assert body["merged_pull_requests"] == [ + { + "number": 10, + "title": "Merged PR", + "html_url": "https://github.com/test/repo/pull/10", + } + ] + assert body["opened_pull_requests"][0]["number"] == 11 + assert body["opened_issues"][0]["number"] == 12 + + def test_graphql_queries_cover_window(self, client, mock_connector_graphql): + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + client.get("/api/v1/project-status", params={"end_date": "2025-04-10"}) + finally: + app.dependency_overrides.clear() + + gql_qs = [] + for c in mock_connector_graphql.post.call_args_list: + args = getattr(c, "args", ()) + body = args[1] if len(args) >= 2 else getattr(c, "kwargs", {}).get("data") + if isinstance(body, dict) and body.get("variables"): + gql_qs.append(body["variables"].get("q") or "") + + assert any("merged:2025-04-04..2025-04-10" in q for q in gql_qs) + assert any("is:merged" in q for q in gql_qs) + assert any( + "repo:test/repo is:pr created:2025-04-04..2025-04-10" in q for q in gql_qs + ) + assert any( + "repo:test/repo is:issue created:2025-04-04..2025-04-10" in q + for q in gql_qs + ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 455461d..001bb38 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import { } from './services/api'; import MilestoneCard from './components/MilestoneCard'; import SdlcKpisPanel from './components/SdlcKpisPanel'; +import ProjectStatusPanel from './components/ProjectStatusPanel'; import ManageMilestones from './components/ManageMilestones'; import ManageLabels from './components/ManageLabels'; import ManageSort from './components/ManageSort'; @@ -30,7 +31,7 @@ import iconImage from './assets/icon.png'; import './icon.css'; const MAIN_VIEW_TAB_STORAGE_KEY = 'pmStatsMainViewTab'; -const VALID_MAIN_VIEW_TABS = new Set(['planning', 'sdlc']); +const VALID_MAIN_VIEW_TABS = new Set(['planning', 'sdlc', 'project-status']); const App = () => { // Initialize with cached data if available @@ -357,6 +358,12 @@ const App = () => { SDLC}> + Project status} + > + + { api.fetchLabels.mockResolvedValue([]); // Default mock for fetchAssignees (preloaded in background) api.fetchAssignees.mockResolvedValue([]); + api.fetchProjectStatusReport = vi.fn().mockResolvedValue({ + start_date: '2025-01-01', + end_date: '2025-01-07', + merged_pull_requests: [], + opened_pull_requests: [], + opened_issues: [], + }); }); afterEach(() => { @@ -262,6 +269,22 @@ describe('App', () => { }); }); + it('restores Project status tab from localStorage', async () => { + store.pmStatsMainViewTab = 'project-status'; + api.fetchMilestones.mockResolvedValue([]); + mockSdlcSeriesResponses(); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect( + screen.getByRole('tab', { name: /^Project status$/i }) + ).toHaveAttribute('aria-selected', 'true'); + }); + }); + it('loads sort order from localStorage on mount', async () => { const savedSortOrder = ['label1', 'label2', 'label3']; localStorageMock.setItem('issueSortOrder', JSON.stringify(savedSortOrder)); diff --git a/frontend/src/components/ProjectStatusPanel.jsx b/frontend/src/components/ProjectStatusPanel.jsx new file mode 100644 index 0000000..681b414 --- /dev/null +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -0,0 +1,226 @@ +// ai-generated: Cursor +import React, { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Card, + CardBody, + CardTitle, + FormGroup, + Spinner, + TextContent, + TextInput, + Title, +} from '@patternfly/react-core'; +import { OutlinedCopyIcon } from '@patternfly/react-icons'; +import { fetchProjectStatusReport } from '../services/api'; +import { copyStatusSectionToClipboard } from '../utils/clipboard'; +import { formatDate, getLocalDateISOString } from '../utils/dateUtils'; + +const emptyListMessage = 'None in this period.'; + +const StatusSection = ({ heading, items }) => { + const hasItems = items && items.length > 0; + const copyLines = () => { + if (!hasItems) { + return; + } + void copyStatusSectionToClipboard(items); + }; + + return ( + + +
+
+
+ + {!items || items.length === 0 ? ( + {emptyListMessage} + ) : ( + + )} + +
+ ); +}; + +const ProjectStatusPanel = () => { + const [endDate, setEndDate] = useState(() => getLocalDateISOString()); + const [draftEndDate, setDraftEndDate] = useState(() => + getLocalDateISOString() + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [report, setReport] = useState(null); + + const load = useCallback(async (iso) => { + setLoading(true); + setError(null); + try { + const data = await fetchProjectStatusReport(iso); + setReport(data); + } catch (e) { + setReport(null); + setError(e?.message || 'Failed to load report'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(endDate); + }, [endDate, load]); + + const onApplyDate = () => { + setEndDate(draftEndDate); + }; + + const windowLabel = + report?.start_date && report?.end_date + ? `${formatDate(`${report.start_date}T12:00:00Z`)} — ${formatDate(`${report.end_date}T12:00:00Z`)}` + : ''; + + return ( +
+ + + Project status + +

+ Seven calendar days ending on the selected date (UTC boundaries on the + server). PRs and issues link to GitHub. +

+
+ + +
+ { + let stringValue = ''; + if (typeof value === 'string') { + stringValue = value; + } else if ( + value && + typeof value === 'object' && + 'target' in value + ) { + stringValue = value.target?.value || ''; + } else if (event && 'target' in event) { + stringValue = event.target?.value || ''; + } + setDraftEndDate(stringValue); + }} + /> + + +
+
+ + {windowLabel && !loading && !error && ( +

+ Reporting window: {windowLabel} +

+ )} + + {loading && ( +
+ + Loading report… +
+ )} + + {error && ( + + {error} + + )} + + {report && !loading && ( + <> + + + + + )} +
+ ); +}; + +export default ProjectStatusPanel; diff --git a/frontend/src/components/ProjectStatusPanel.test.jsx b/frontend/src/components/ProjectStatusPanel.test.jsx new file mode 100644 index 0000000..a9bfd21 --- /dev/null +++ b/frontend/src/components/ProjectStatusPanel.test.jsx @@ -0,0 +1,86 @@ +// ai-generated: Cursor +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ProjectStatusPanel from './ProjectStatusPanel'; +import * as api from '../services/api'; +import * as clipboard from '../utils/clipboard'; + +vi.mock('../services/api'); +vi.mock('../utils/clipboard', () => ({ + copyStatusSectionToClipboard: vi.fn().mockResolvedValue(undefined), +})); + +describe('ProjectStatusPanel', () => { + const merged = [ + { + number: 1, + title: 'Fix bug', + html_url: 'https://github.com/o/r/pull/1', + }, + ]; + const issues = [ + { + number: 2, + title: 'Track work', + html_url: 'https://github.com/o/r/issues/2', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + api.fetchProjectStatusReport.mockResolvedValue({ + start_date: '2025-04-04', + end_date: '2025-04-10', + merged_pull_requests: merged, + opened_pull_requests: [], + opened_issues: issues, + }); + }); + + it('loads report and renders linked rows', async () => { + render(); + + await waitFor(() => { + expect(api.fetchProjectStatusReport).toHaveBeenCalled(); + }); + + const prLink = await screen.findByRole('link', { name: '#1' }); + expect(prLink).toHaveAttribute('href', 'https://github.com/o/r/pull/1'); + expect(screen.getByText('Fix bug')).toBeInTheDocument(); + + const issueLink = screen.getByRole('link', { name: '#2' }); + expect(issueLink).toHaveAttribute( + 'href', + 'https://github.com/o/r/issues/2' + ); + expect(screen.getByText('Track work')).toBeInTheDocument(); + }); + + it('copy button passes section items to clipboard helper', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect( + screen.getByRole('button', { + name: 'Copy Merged pull requests to clipboard', + }) + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole('button', { + name: 'Copy Merged pull requests to clipboard', + }) + ); + expect(clipboard.copyStatusSectionToClipboard).toHaveBeenCalledWith(merged); + + await user.click( + screen.getByRole('button', { + name: 'Copy New issues opened to clipboard', + }) + ); + expect(clipboard.copyStatusSectionToClipboard).toHaveBeenCalledWith(issues); + }); +}); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 2017df7..1c5a9bd 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -243,3 +243,22 @@ export const fetchBugBacklogDelta = async (weeks = 4, weekDays = 7) => { } return response.json(); }; + +/** @param {string} endDateISO ``YYYY-MM-DD`` — last day of the 7-day window (UTC). */ +export const fetchProjectStatusReport = async (endDateISO) => { + const params = new URLSearchParams(); + if (endDateISO) { + params.set('end_date', endDateISO); + } + const qs = params.toString(); + const url = qs + ? `${API_BASE}/project-status?${qs}` + : `${API_BASE}/project-status`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch project status report: ${response.statusText}` + ); + } + return response.json(); +}; diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 585d93a..1815b1f 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -8,6 +8,7 @@ import { fetchSdlcDelivery, fetchEscapedDefectRate, fetchBugBacklogDelta, + fetchProjectStatusReport, } from './api'; describe('api', () => { @@ -169,4 +170,44 @@ describe('api', () => { ); }); }); + + describe('fetchProjectStatusReport', () => { + it('requests report with end_date', async () => { + const body = { + start_date: '2025-04-04', + end_date: '2025-04-10', + merged_pull_requests: [], + opened_pull_requests: [], + opened_issues: [], + }; + global.fetch.mockResolvedValue({ ok: true, json: async () => body }); + const result = await fetchProjectStatusReport('2025-04-10'); + expect(result).toEqual(body); + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/project-status?end_date=2025-04-10' + ); + }); + + it('requests report without query when end date omitted', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + start_date: '2025-01-01', + end_date: '2025-01-07', + merged_pull_requests: [], + opened_pull_requests: [], + opened_issues: [], + }), + }); + await fetchProjectStatusReport(''); + expect(global.fetch).toHaveBeenCalledWith('/api/v1/project-status'); + }); + + it('throws on failure', async () => { + global.fetch.mockResolvedValue({ ok: false, statusText: 'Bad Gateway' }); + await expect(fetchProjectStatusReport('2025-04-10')).rejects.toThrow( + 'Failed to fetch project status report' + ); + }); + }); }); diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js new file mode 100644 index 0000000..f0f77e1 --- /dev/null +++ b/frontend/src/utils/clipboard.js @@ -0,0 +1,106 @@ +// ai-generated: Cursor +/** + * @param {string} s + * @returns {string} + */ +function escapeHtmlText(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * @param {string} s + * @returns {string} + */ +function escapeHtmlAttr(s) { + return String(s).replace(/&/g, '&').replace(/"/g, '"'); +} + +/** + * Markdown lines: linked ``#number`` then space and title (for editors that read plain text). + * + * @param {Array<{ number: number, title?: string, html_url?: string }>} items + * @returns {string} + */ +export function formatStatusSectionClipboardMarkdown(items) { + return (items || []) + .map((row) => { + const title = (row.title != null ? String(row.title) : '').trim(); + const url = (row.html_url != null ? String(row.html_url) : '').trim(); + if (!url) { + return `#${row.number} ${title}`.trim(); + } + return `[#${row.number}](${url}) ${title}`.trim(); + }) + .join('\n'); +} + +/** + * Minimal HTML (one line per item) so paste into Word / email keeps the number as a hyperlink. + * + * @param {Array<{ number: number, title?: string, html_url?: string }>} items + * @returns {string} + */ +export function formatStatusSectionClipboardHtml(items) { + return (items || []) + .map((row) => { + const title = escapeHtmlText( + (row.title != null ? String(row.title) : '').trim() + ); + const url = (row.html_url != null ? String(row.html_url) : '').trim(); + if (!url) { + return `#${row.number} ${title}`; + } + const href = escapeHtmlAttr(url); + return `#${row.number} ${title}`; + }) + .join('
\n'); +} + +/** + * @param {string} text + * @returns {Promise} + */ +export async function copyTextToClipboard(text) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + await navigator.clipboard.writeText(text); +} + +/** + * Copy section rows with ``#number`` hyperlinked in HTML and Markdown link syntax in plain text. + * + * @param {Array<{ number: number, title?: string, html_url?: string }>} items + * @returns {Promise} + */ +export async function copyStatusSectionToClipboard(items) { + const plain = formatStatusSectionClipboardMarkdown(items); + if (!plain) { + return; + } + const innerHtml = formatStatusSectionClipboardHtml(items); + const htmlDoc = `
${innerHtml}
`; + + if ( + typeof navigator !== 'undefined' && + navigator.clipboard?.write && + typeof ClipboardItem !== 'undefined' + ) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': new Blob([plain], { type: 'text/plain' }), + 'text/html': new Blob([htmlDoc], { type: 'text/html' }), + }), + ]); + return; + } catch (e) { + console.warn('clipboard.write failed, falling back to text only', e); + } + } + await copyTextToClipboard(plain); +} diff --git a/frontend/src/utils/clipboard.test.js b/frontend/src/utils/clipboard.test.js new file mode 100644 index 0000000..7fb1385 --- /dev/null +++ b/frontend/src/utils/clipboard.test.js @@ -0,0 +1,112 @@ +// ai-generated: Cursor +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + copyTextToClipboard, + copyStatusSectionToClipboard, + formatStatusSectionClipboardHtml, + formatStatusSectionClipboardMarkdown, +} from './clipboard'; + +describe('formatStatusSectionClipboardMarkdown', () => { + it('uses Markdown links when html_url is present', () => { + const text = formatStatusSectionClipboardMarkdown([ + { + number: 42, + title: 'Hello world', + html_url: 'https://github.com/o/r/pull/42', + }, + { + number: 7, + title: ' spaced ', + html_url: 'https://github.com/o/r/issues/7', + }, + ]); + expect(text).toBe( + '[#42](https://github.com/o/r/pull/42) Hello world\n[#7](https://github.com/o/r/issues/7) spaced' + ); + }); + + it('falls back to #n title when url is missing', () => { + expect( + formatStatusSectionClipboardMarkdown([{ number: 1, title: 'No url' }]) + ).toBe('#1 No url'); + }); + + it('returns empty string for empty list', () => { + expect(formatStatusSectionClipboardMarkdown([])).toBe(''); + expect(formatStatusSectionClipboardMarkdown(null)).toBe(''); + }); +}); + +describe('formatStatusSectionClipboardHtml', () => { + it('wraps the number in an anchor when html_url is present', () => { + const html = formatStatusSectionClipboardHtml([ + { number: 1, title: 'A & B', html_url: 'https://github.com/o/r/pull/1' }, + ]); + expect(html).toContain('#1'); + expect(html).toContain('A & B'); + }); +}); + +describe('copyTextToClipboard', () => { + beforeEach(() => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + writable: true, + }); + }); + + it('writes via the Clipboard API when available', async () => { + await copyTextToClipboard('hello'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello'); + }); +}); + +describe('copyStatusSectionToClipboard', () => { + beforeEach(() => { + globalThis.ClipboardItem = class { + constructor(data) { + this._data = data; + } + }; + }); + + it('uses clipboard.write with HTML and plain when supported', async () => { + const write = vi.fn().mockResolvedValue(undefined); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { write, writeText }, + configurable: true, + writable: true, + }); + + await copyStatusSectionToClipboard([ + { number: 1, title: 'T', html_url: 'https://github.com/o/r/pull/1' }, + ]); + + expect(write).toHaveBeenCalledTimes(1); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('falls back to writeText when write throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const write = vi.fn().mockRejectedValue(new Error('no')); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { write, writeText }, + configurable: true, + writable: true, + }); + + await copyStatusSectionToClipboard([ + { number: 1, title: 'T', html_url: 'https://github.com/o/r/pull/1' }, + ]); + + expect(writeText).toHaveBeenCalledWith( + '[#1](https://github.com/o/r/pull/1) T' + ); + warn.mockRestore(); + }); +}); diff --git a/frontend/src/utils/dateUtils.js b/frontend/src/utils/dateUtils.js index df5009d..d6b9ed2 100644 --- a/frontend/src/utils/dateUtils.js +++ b/frontend/src/utils/dateUtils.js @@ -1,4 +1,12 @@ // ai-generated: Cursor +/** Today's date in the user's local timezone as ``YYYY-MM-DD`` (for ````). */ +export const getLocalDateISOString = (d = new Date()) => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + export const getDaysSince = (dateString) => { if (!dateString) return 0; const date = new Date(dateString); diff --git a/frontend/src/utils/dateUtils.test.js b/frontend/src/utils/dateUtils.test.js index 5e54a57..7665509 100644 --- a/frontend/src/utils/dateUtils.test.js +++ b/frontend/src/utils/dateUtils.test.js @@ -1,6 +1,6 @@ // ai-generated: Cursor import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getDaysSince, formatDate } from './dateUtils'; +import { getDaysSince, formatDate, getLocalDateISOString } from './dateUtils'; describe('dateUtils', () => { beforeEach(() => { @@ -49,4 +49,10 @@ describe('dateUtils', () => { expect(formatDate(undefined)).toBe('No date'); }); }); + + describe('getLocalDateISOString', () => { + it('formats a local calendar date as YYYY-MM-DD', () => { + expect(getLocalDateISOString(new Date(2025, 5, 5))).toBe('2025-06-05'); + }); + }); });