diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index aa3f32f..054cfc1 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -358,7 +358,91 @@ def graphql_search_pull_requests( title url createdAt + updatedAt + state + isDraft + mergedAt + additions + deletions + labels(first: 30) { nodes { name } } + milestone { title } + author { + __typename + ... on User { login } + ... on Bot { login } + ... on Organization { login } + } + } + } + } + } + """ + 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 [] + 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_open_pull_requests_attention( + post_graphql: Callable[[dict[str, Any]], dict[str, Any]], + search_query: str, + *, + page_size: int = 100, + filter_bot_authors: bool = False, +) -> list[dict[str, Any]]: + """Paginate GitHub GraphQL search for open PRs including ``mergeable``, + ``mergeStateStatus``, and ``reviews``. + + Used for project status "attention" sections; callers filter on merge state and + review timestamps. + """ + 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 { + ... on PullRequest { + number + title + url + createdAt + updatedAt + state + isDraft mergedAt + mergeable + mergeStateStatus + reviews(first: 100) { + nodes { + submittedAt + state + } + } additions deletions labels(first: 30) { nodes { name } } @@ -496,6 +580,32 @@ def opened_prs_between_query(github_repo: str, start_d: date, end_d: date) -> st return f"{repo_search_fragment(github_repo)} is:pr created:{a}..{b}" +def open_pr_backlog_query(github_repo: str, start_d: date) -> str: + """Open, non-draft PRs last updated strictly before ``start_d`` (UTC calendar day at 00:00). + + Uses ``draft:false`` and ``updated: str: + """All open pull requests in the repo (drafts included) for merge/review heuristics.""" + return f"{repo_search_fragment(github_repo)} is:pr is:open" + + +def open_prs_updated_between_query(github_repo: str, start_d: date, end_d: date) -> str: + """Open, non-draft PRs with ``updated`` 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 is:open draft:false " + f"updated:{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() diff --git a/backend/src/github_pm/status_report_api.py b/backend/src/github_pm/status_report_api.py index ed6c8a3..a2ee89d 100644 --- a/backend/src/github_pm/status_report_api.py +++ b/backend/src/github_pm/status_report_api.py @@ -44,7 +44,11 @@ async def get_project_status_report( Defaults: ``end_date`` = today (UTC), ``start_date`` = ``end_date`` minus 7 calendar days. - Sections: merged pull requests (by merge date), pull requests opened, issues opened (PRs excluded). + Sections: merged pull requests (by merge date), pull requests opened, issues + opened (PRs excluded), recently updated PRs (open, non-draft, touched in the + window but not opened in it), reviewer and creator attention (current open PRs + by merge/review state), and PR backlog (open, non-draft PRs not updated on or + after ``start_date``, UTC calendar dates). """ resolved_end = end_date if end_date is not None else _default_end_date() resolved_start = ( diff --git a/backend/src/github_pm/status_report_models.py b/backend/src/github_pm/status_report_models.py index 5cad704..4d85fe3 100644 --- a/backend/src/github_pm/status_report_models.py +++ b/backend/src/github_pm/status_report_models.py @@ -18,6 +18,18 @@ class StatusReportItem(BaseModel): html_url: str = Field(description="GitHub HTML URL for the issue or PR") +class PrBacklogItem(StatusReportItem): + """Open stale PR row including age relative to the report ``end_date``.""" + + days_since_update: int = Field( + ge=0, + description=( + "Whole calendar days from the PR's last ``updatedAt`` (UTC date) through " + "``end_date`` (inclusive), matching the report window's last day" + ), + ) + + class ProjectStatusReportResponse(BaseModel): """Inclusive calendar window from ``start_date`` through ``end_date`` (UTC calendar dates).""" @@ -35,3 +47,33 @@ class ProjectStatusReportResponse(BaseModel): default_factory=list, description="Issues created in the window (pull requests excluded)", ) + recently_updated_pull_requests: list[StatusReportItem] = Field( + default_factory=list, + description=( + "Open, non-draft pull requests updated in the window whose ``createdAt`` " + "UTC calendar date is outside the window (not newly opened this period)" + ), + ) + reviewer_attention_needed: list[StatusReportItem] = Field( + default_factory=list, + description=( + "Open, non-draft PRs with ``mergeable`` MERGEABLE and a clean branch " + "(``mergeStateStatus`` not BEHIND or DIRTY), with no submitted review or " + "``updatedAt`` after the latest review ``submittedAt``" + ), + ) + creator_attention_needed: list[StatusReportItem] = Field( + default_factory=list, + description=( + "Open PRs (including drafts) that need branch work or author follow-up: " + "``mergeable`` CONFLICTING, ``mergeStateStatus`` BEHIND or DIRTY, or latest " + "submitted review newer than ``updatedAt``" + ), + ) + pr_backlog: list[PrBacklogItem] = Field( + default_factory=list, + description=( + "Open, non-draft pull requests whose last update (UTC) is strictly before " + "start_date — not merged or closed, stale since the report window began" + ), + ) diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py index 8feeb01..0256322 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -5,12 +5,13 @@ from __future__ import annotations -from datetime import date, UTC +from datetime import date, datetime, 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 ( + PrBacklogItem, ProjectStatusReportResponse, StatusReportItem, ) @@ -44,6 +45,93 @@ def _created_calendar_in_window( return start_d <= cd <= end_d +def _updated_calendar_in_window( + node: dict[str, Any], start_d: date, end_d: date +) -> bool: + u = sm.parse_github_ts(node.get("updatedAt")) + if not u: + return False + ud = u.astimezone(UTC).date() + return start_d <= ud <= end_d + + +def _updated_strictly_before_start_date(node: dict[str, Any], start_d: date) -> bool: + """True if ``updatedAt`` exists and its UTC calendar date is before ``start_d``.""" + u = sm.parse_github_ts(node.get("updatedAt")) + if not u: + return False + return u.astimezone(UTC).date() < start_d + + +def _calendar_days_since_update_to_end(node: dict[str, Any], end_d: date) -> int: + """Calendar days from ``updatedAt`` UTC date through ``end_d`` (inclusive span).""" + u = sm.parse_github_ts(node.get("updatedAt")) + if not u: + return 0 + ud = u.astimezone(UTC).date() + return max(0, (end_d - ud).days) + + +def _backlog_item_from_gql_node(node: dict[str, Any], end_d: date) -> PrBacklogItem: + base = _item_from_gql_node(node) + return PrBacklogItem( + number=base.number, + title=base.title, + html_url=base.html_url, + days_since_update=_calendar_days_since_update_to_end(node, end_d), + ) + + +def _max_submitted_review_time(node: dict[str, Any]) -> datetime | None: + """Latest ``submittedAt`` among non-pending PR reviews, or ``None`` if none.""" + best: datetime | None = None + for r in (node.get("reviews") or {}).get("nodes") or []: + if not isinstance(r, dict): + continue + if r.get("state") == "PENDING": + continue + ts = sm.parse_github_ts(r.get("submittedAt")) + if ts is None: + continue + if best is None or ts > best: + best = ts + return best + + +def _partition_attention_open_prs( + nodes: list[dict[str, Any]], +) -> tuple[list[StatusReportItem], list[StatusReportItem]]: + """Split open PR nodes into reviewer vs creator attention lists.""" + reviewer_out: list[StatusReportItem] = [] + creator_out: list[StatusReportItem] = [] + for n in nodes: + if n.get("state") != "OPEN" or n.get("mergedAt"): + continue + updated = sm.parse_github_ts(n.get("updatedAt")) + if updated is None: + continue + last_review = _max_submitted_review_time(n) + mergeable = str(n.get("mergeable") or "") + merge_state = str(n.get("mergeStateStatus") or "") + is_draft = bool(n.get("isDraft")) + needs_creator_branch = mergeable == "CONFLICTING" or merge_state in ( + "BEHIND", + "DIRTY", + ) + if ( + not is_draft + and mergeable == "MERGEABLE" + and merge_state not in ("BEHIND", "DIRTY") + and (last_review is None or updated > last_review) + ): + reviewer_out.append(_item_from_gql_node(n)) + if needs_creator_branch or (last_review is not None and last_review > updated): + creator_out.append(_item_from_gql_node(n)) + reviewer_out.sort(key=lambda x: x.number) + creator_out.sort(key=lambda x: x.number) + return reviewer_out, creator_out + + def build_project_status_report( gitctx: Connector, *, @@ -91,10 +179,59 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: ] opened_issue_filtered.sort(key=lambda n: int(n["number"])) + recently_q = sm.open_prs_updated_between_query(repo, start_date, end_date) + recently_nodes = sm.graphql_search_pull_requests( + post_gql, + recently_q, + filter_bot_authors=False, + ) + recently_filtered = [ + n + for n in recently_nodes + if n.get("state") == "OPEN" + and not n.get("mergedAt") + and not n.get("isDraft") + and _updated_calendar_in_window(n, start_date, end_date) + and not _created_calendar_in_window(n, start_date, end_date) + ] + recently_filtered.sort(key=lambda n: int(n["number"])) + recently_items = [_item_from_gql_node(n) for n in recently_filtered] + + attention_q = sm.open_pull_requests_for_attention_query(repo) + attention_nodes = sm.graphql_search_open_pull_requests_attention( + post_gql, + attention_q, + filter_bot_authors=False, + ) + reviewer_attention, creator_attention = _partition_attention_open_prs( + attention_nodes + ) + + backlog_q = sm.open_pr_backlog_query(repo, start_date) + backlog_nodes = sm.graphql_search_pull_requests( + post_gql, + backlog_q, + filter_bot_authors=False, + ) + backlog_filtered = [ + n + for n in backlog_nodes + if n.get("state") == "OPEN" + and not n.get("mergedAt") + and not n.get("isDraft") + and _updated_strictly_before_start_date(n, start_date) + ] + backlog_filtered.sort(key=lambda n: int(n["number"])) + backlog_items = [_backlog_item_from_gql_node(n, end_date) for n in backlog_filtered] + 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], + recently_updated_pull_requests=recently_items, + reviewer_attention_needed=reviewer_attention, + creator_attention_needed=creator_attention, + pr_backlog=backlog_items, ) diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py index 53a3a1a..b0ed3a2 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -22,6 +22,9 @@ def mock_connector_graphql(): gitctx = MagicMock() gitctx.owner = "test" gitctx.repo = "repo" + gitctx.status_backlog_nodes = [] + gitctx.status_recently_updated_nodes = [] + gitctx.status_attention_open_nodes = [] merged_nodes = [ { @@ -82,6 +85,33 @@ def post_side(path: str, data=None, **kwargs): } } } + if q == "repo:test/repo is:pr is:open": + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": list(gitctx.status_attention_open_nodes), + } + } + } + if "is:pr" in q and "is:open" in q and "updated:" in q: + if "updated:<" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": list(gitctx.status_backlog_nodes), + } + } + } + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": list(gitctx.status_recently_updated_nodes), + } + } + } if "is:pr" in q and "created:" in q: return { "data": { @@ -123,6 +153,10 @@ async def override_conn(): ] assert body["opened_pull_requests"][0]["number"] == 11 assert body["opened_issues"][0]["number"] == 12 + assert body["recently_updated_pull_requests"] == [] + assert body["reviewer_attention_needed"] == [] + assert body["creator_attention_needed"] == [] + assert body["pr_backlog"] == [] def test_report_single_calendar_day(self, client, mock_connector_graphql): async def override_conn(): @@ -219,6 +253,290 @@ async def override_conn(): "repo:test/repo is:issue created:2025-04-04..2025-04-10" in q for q in gql_qs ) + assert any( + "repo:test/repo is:pr is:open draft:false updated:<2025-04-04" in q + for q in gql_qs + ) + assert any( + "repo:test/repo is:pr is:open draft:false updated:2025-04-04..2025-04-10" + in q + for q in gql_qs + ) + assert any(q == "repo:test/repo is:pr is:open" for q in gql_qs) + + def test_attention_sections_partition_open_prs( + self, client, mock_connector_graphql + ): + mock_connector_graphql.status_attention_open_nodes = [ + { + "number": 70, + "title": "Needs first review", + "url": "https://github.com/test/repo/pull/70", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-04-05T12:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "reviews": {"nodes": []}, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 71, + "title": "Pushed after review", + "url": "https://github.com/test/repo/pull/71", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-04-06T12:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "reviews": { + "nodes": [ + { + "submittedAt": "2025-04-03T10:00:00Z", + "state": "COMMENTED", + } + ] + }, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 72, + "title": "Needs author response", + "url": "https://github.com/test/repo/pull/72", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-04-05T12:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "reviews": { + "nodes": [ + { + "submittedAt": "2025-04-07T10:00:00Z", + "state": "CHANGES_REQUESTED", + } + ] + }, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 73, + "title": "Conflicted", + "url": "https://github.com/test/repo/pull/73", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-04-04T12:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "mergeable": "CONFLICTING", + "mergeStateStatus": "DIRTY", + "reviews": {"nodes": []}, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 74, + "title": "Behind base branch", + "url": "https://github.com/test/repo/pull/74", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-04-05T12:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "mergeable": "MERGEABLE", + "mergeStateStatus": "BEHIND", + "reviews": {"nodes": []}, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + ] + + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-04", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + body = r.json() + assert {p["number"] for p in body["reviewer_attention_needed"]} == {70, 71} + assert {p["number"] for p in body["creator_attention_needed"]} == {72, 73, 74} + + def test_recently_updated_pull_requests_excludes_opened_in_window( + self, client, mock_connector_graphql + ): + mock_connector_graphql.status_recently_updated_nodes = [ + { + "number": 60, + "title": "Older PR touched now", + "url": "https://github.com/test/repo/pull/60", + "createdAt": "2025-01-10T10:00:00Z", + "updatedAt": "2025-04-06T15:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 61, + "title": "Opened and updated same window", + "url": "https://github.com/test/repo/pull/61", + "createdAt": "2025-04-05T12:00:00Z", + "updatedAt": "2025-04-06T15:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 62, + "title": "Draft touched", + "url": "https://github.com/test/repo/pull/62", + "createdAt": "2025-01-10T10:00:00Z", + "updatedAt": "2025-04-06T15:00:00Z", + "state": "OPEN", + "isDraft": True, + "mergedAt": None, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + ] + + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-04", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + recent = r.json()["recently_updated_pull_requests"] + assert recent == [ + { + "number": 60, + "title": "Older PR touched now", + "html_url": "https://github.com/test/repo/pull/60", + } + ] + + def test_pr_backlog_includes_open_prs_updated_before_start( + self, client, mock_connector_graphql + ): + mock_connector_graphql.status_backlog_nodes = [ + { + "number": 50, + "title": "Stale open", + "url": "https://github.com/test/repo/pull/50", + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-04-03T23:59:59Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 51, + "title": "Touched on start day", + "url": "https://github.com/test/repo/pull/51", + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-04-04T00:00:00Z", + "state": "OPEN", + "isDraft": False, + "mergedAt": None, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + { + "number": 52, + "title": "Stale draft", + "url": "https://github.com/test/repo/pull/52", + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-04-03T10:00:00Z", + "state": "OPEN", + "isDraft": True, + "mergedAt": None, + "additions": 1, + "deletions": 0, + "labels": {"nodes": []}, + "milestone": None, + "author": {"__typename": "User", "login": "u"}, + }, + ] + + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-04", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + backlog = r.json()["pr_backlog"] + assert backlog == [ + { + "number": 50, + "title": "Stale open", + "html_url": "https://github.com/test/repo/pull/50", + "days_since_update": 7, + } + ] def test_opened_prs_exclude_closed_without_merge(self, client): """PRs with GitHub state CLOSED (not merged) must not appear in opened_pull_requests.""" @@ -280,6 +598,33 @@ def post_side(path: str, data=None, **kwargs): } } } + if q == "repo:test/repo is:pr is:open": + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [], + } + } + } + if "is:pr" in q and "is:open" in q and "updated:" in q: + if "updated:<" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [], + } + } + } + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [], + } + } + } if "is:pr" in q and "created:" in q: return { "data": { @@ -309,3 +654,7 @@ async def override_conn(): opened = r.json()["opened_pull_requests"] numbers = {p["number"] for p in opened} assert numbers == {20, 21} + assert r.json()["recently_updated_pull_requests"] == [] + assert r.json()["reviewer_attention_needed"] == [] + assert r.json()["creator_attention_needed"] == [] + assert r.json()["pr_backlog"] == [] diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index 8ffe601..af0100e 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -56,6 +56,10 @@ describe('App', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], + pr_backlog: [], }); }); diff --git a/frontend/src/components/ProjectStatusPanel.jsx b/frontend/src/components/ProjectStatusPanel.jsx index 0abe3bc..f009c70 100644 --- a/frontend/src/components/ProjectStatusPanel.jsx +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -50,7 +50,31 @@ const getInitialDateRange = () => { const emptyListMessage = 'None in this period.'; -const StatusSection = ({ heading, items }) => { +const backlogAgeSuffix = (row) => { + const n = row.days_since_update; + if (typeof n !== 'number' || Number.isNaN(n)) { + return null; + } + const unit = n === 1 ? 'day' : 'days'; + return ( + + ({n} {unit}) + + ); +}; + +const StatusSection = ({ + heading, + description, + items, + renderTitleSuffix, + emptyMessage, +}) => { const hasItems = items && items.length > 0; const copyLines = () => { if (!hasItems) { @@ -59,34 +83,58 @@ const StatusSection = ({ heading, items }) => { void copyStatusSectionToClipboard(items); }; + const emptyText = emptyMessage ?? emptyListMessage; + return (
-
+ {description ? ( +

+ {description} +

+ ) : null}
{!items || items.length === 0 ? ( - {emptyListMessage} + {emptyText} ) : (
    {items.map((row) => ( @@ -101,7 +149,10 @@ const StatusSection = ({ heading, items }) => { > #{row.number} - {row.title} + + {row.title} + {renderTitleSuffix ? renderTitleSuffix(row) : null} + ))}
@@ -275,16 +326,42 @@ const ProjectStatusPanel = () => { <> + + + + )} diff --git a/frontend/src/components/ProjectStatusPanel.test.jsx b/frontend/src/components/ProjectStatusPanel.test.jsx index fac70e2..9b379ba 100644 --- a/frontend/src/components/ProjectStatusPanel.test.jsx +++ b/frontend/src/components/ProjectStatusPanel.test.jsx @@ -42,6 +42,10 @@ describe('ProjectStatusPanel', () => { merged_pull_requests: merged, opened_pull_requests: [], opened_issues: issues, + recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], + pr_backlog: [], }); }); @@ -72,6 +76,32 @@ describe('ProjectStatusPanel', () => { expect(screen.getByText('Track work')).toBeInTheDocument(); }); + it('shows days since update in PR backlog section', async () => { + api.fetchProjectStatusReport.mockResolvedValue({ + start_date: '2025-04-04', + end_date: '2025-04-10', + merged_pull_requests: [], + opened_pull_requests: [], + opened_issues: [], + recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], + pr_backlog: [ + { + number: 50, + title: 'Stale open', + html_url: 'https://github.com/o/r/pull/50', + days_since_update: 7, + }, + ], + }); + render(); + await waitFor(() => { + expect(screen.getByText('Stale open')).toBeInTheDocument(); + }); + expect(screen.getByText('(7 days)')).toBeInTheDocument(); + }); + it('copy button passes section items to clipboard helper', async () => { const user = userEvent.setup(); render(); @@ -82,6 +112,18 @@ describe('ProjectStatusPanel', () => { name: 'Copy Merged pull requests to clipboard', }) ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Recently updated PRs' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Reviewer attention needed' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Creator attention needed' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'PR backlog' }) + ).toBeInTheDocument(); }); await user.click( @@ -107,6 +149,10 @@ describe('ProjectStatusPanel', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], + pr_backlog: [], }); render(); diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 9bbce09..d384dc1 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -179,6 +179,10 @@ describe('api', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], + pr_backlog: [], }; global.fetch.mockResolvedValue({ ok: true, json: async () => body }); const result = await fetchProjectStatusReport('2025-04-04', '2025-04-10'); @@ -197,6 +201,10 @@ describe('api', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], + pr_backlog: [], }), }); await fetchProjectStatusReport('', ''); diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js index 25bf399..41a50e0 100644 --- a/frontend/src/utils/clipboard.js +++ b/frontend/src/utils/clipboard.js @@ -19,21 +19,35 @@ function escapeHtmlAttr(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"'); } +/** + * @param {Record} row + * @returns {string} + */ +function statusItemAgeSuffix(row) { + const n = row.days_since_update; + if (typeof n !== 'number' || Number.isNaN(n)) { + return ''; + } + const unit = n === 1 ? 'day' : 'days'; + return ` (${n} ${unit})`; +} + /** * Markdown lines: linked ``#number`` then space and title (for editors that read plain text). * - * @param {Array<{ number: number, title?: string, html_url?: string }>} items + * @param {Array<{ number: number, title?: string, html_url?: string, days_since_update?: number }>} items * @returns {string} */ export function formatStatusSectionClipboardMarkdown(items) { return (items || []) .map((row) => { const title = (row.title != null ? String(row.title) : '').trim(); + const age = statusItemAgeSuffix(row); const url = (row.html_url != null ? String(row.html_url) : '').trim(); if (!url) { - return `#${row.number} ${title}`.trim(); + return `#${row.number} ${title}${age}`.trim(); } - return `[#${row.number}](${url}) ${title}`.trim(); + return `[#${row.number}](${url}) ${title}${age}`.trim(); }) .join('\n'); } @@ -41,7 +55,7 @@ export function formatStatusSectionClipboardMarkdown(items) { /** * 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 + * @param {Array<{ number: number, title?: string, html_url?: string, days_since_update?: number }>} items * @returns {string} */ export function formatStatusSectionClipboardHtml(items) { @@ -50,12 +64,13 @@ export function formatStatusSectionClipboardHtml(items) { const title = escapeHtmlText( (row.title != null ? String(row.title) : '').trim() ); + const age = escapeHtmlText(statusItemAgeSuffix(row)); const url = (row.html_url != null ? String(row.html_url) : '').trim(); if (!url) { - return `#${row.number} ${title}`; + return `#${row.number} ${title}${age}`; } const href = escapeHtmlAttr(url); - return `#${row.number} ${title}`; + return `#${row.number} ${title}${age}`; }) .join('
\n'); } @@ -74,7 +89,7 @@ export async function copyTextToClipboard(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 + * @param {Array<{ number: number, title?: string, html_url?: string, days_since_update?: number }>} items * @returns {Promise} */ export async function copyStatusSectionToClipboard(items) { diff --git a/frontend/src/utils/clipboard.test.js b/frontend/src/utils/clipboard.test.js index 6b5399e..b55482d 100644 --- a/frontend/src/utils/clipboard.test.js +++ b/frontend/src/utils/clipboard.test.js @@ -36,6 +36,27 @@ describe('formatStatusSectionClipboardMarkdown', () => { expect(formatStatusSectionClipboardMarkdown([])).toBe(''); expect(formatStatusSectionClipboardMarkdown(null)).toBe(''); }); + + it('appends age suffix for PR backlog rows with days_since_update', () => { + expect( + formatStatusSectionClipboardMarkdown([ + { + number: 50, + title: 'Stale open', + html_url: 'https://github.com/o/r/pull/50', + days_since_update: 7, + }, + { + number: 51, + title: 'One day', + html_url: 'https://github.com/o/r/pull/51', + days_since_update: 1, + }, + ]) + ).toBe( + '[#50](https://github.com/o/r/pull/50) Stale open (7 days)\n[#51](https://github.com/o/r/pull/51) One day (1 day)' + ); + }); }); describe('formatStatusSectionClipboardHtml', () => { @@ -46,6 +67,18 @@ describe('formatStatusSectionClipboardHtml', () => { expect(html).toContain('#1'); expect(html).toContain('A & B'); }); + + it('escapes age suffix in HTML clipboard output', () => { + const html = formatStatusSectionClipboardHtml([ + { + number: 50, + title: 'T', + html_url: 'https://github.com/o/r/pull/50', + days_since_update: 3, + }, + ]); + expect(html).toContain('T (3 days)'); + }); }); describe('copyTextToClipboard', () => {