From 8f70466df7818a6e118428921b59037bb86c5114 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Mon, 11 May 2026 12:32:56 -0400 Subject: [PATCH 1/4] Add "PR backlog" report Report on non-draft PRs which are open and haven't been active within the reporting period. Assisted-by: Cursor Signed-off-by: David Butenhof --- backend/src/github_pm/sdlc_metrics.py | 13 +++ backend/src/github_pm/status_report_api.py | 4 +- backend/src/github_pm/status_report_models.py | 7 ++ .../src/github_pm/status_report_service.py | 31 ++++++- backend/tests/test_status_report_api.py | 92 +++++++++++++++++++ frontend/src/App.test.jsx | 1 + .../src/components/ProjectStatusPanel.jsx | 1 + .../components/ProjectStatusPanel.test.jsx | 5 + frontend/src/services/api.test.js | 2 + 9 files changed, 151 insertions(+), 5 deletions(-) diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index aa3f32f..f814c83 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -358,6 +358,9 @@ def graphql_search_pull_requests( title url createdAt + updatedAt + state + isDraft mergedAt additions deletions @@ -496,6 +499,16 @@ 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: """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..a9b7cf7 100644 --- a/backend/src/github_pm/status_report_api.py +++ b/backend/src/github_pm/status_report_api.py @@ -44,7 +44,9 @@ 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), 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..9a4dfb1 100644 --- a/backend/src/github_pm/status_report_models.py +++ b/backend/src/github_pm/status_report_models.py @@ -35,3 +35,10 @@ class ProjectStatusReportResponse(BaseModel): default_factory=list, description="Issues created in the window (pull requests excluded)", ) + pr_backlog: list[StatusReportItem] = 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..58fbe56 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -10,10 +10,7 @@ from github_pm import sdlc_metrics as sm from github_pm.api import Connector -from github_pm.status_report_models import ( - ProjectStatusReportResponse, - StatusReportItem, -) +from github_pm.status_report_models import ProjectStatusReportResponse, StatusReportItem def _item_from_gql_node(node: dict[str, Any]) -> StatusReportItem: @@ -44,6 +41,14 @@ def _created_calendar_in_window( return start_d <= cd <= 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 build_project_status_report( gitctx: Connector, *, @@ -91,10 +96,28 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: ] opened_issue_filtered.sort(key=lambda n: int(n["number"])) + 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 = [_item_from_gql_node(n) 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], + pr_backlog=backlog_items, ) diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py index 53a3a1a..2260183 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -22,6 +22,7 @@ def mock_connector_graphql(): gitctx = MagicMock() gitctx.owner = "test" gitctx.repo = "repo" + gitctx.status_backlog_nodes = [] merged_nodes = [ { @@ -82,6 +83,15 @@ def post_side(path: str, data=None, **kwargs): } } } + if "is:pr" in q and "is:open" in q and "updated:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": list(gitctx.status_backlog_nodes), + } + } + } if "is:pr" in q and "created:" in q: return { "data": { @@ -123,6 +133,7 @@ async def override_conn(): ] assert body["opened_pull_requests"][0]["number"] == 11 assert body["opened_issues"][0]["number"] == 12 + assert body["pr_backlog"] == [] def test_report_single_calendar_day(self, client, mock_connector_graphql): async def override_conn(): @@ -219,6 +230,77 @@ 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 + ) + + 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 [p["number"] for p in backlog] == [50] 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 +362,15 @@ def post_side(path: str, data=None, **kwargs): } } } + if "is:pr" in q and "is:open" in q and "updated:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [], + } + } + } if "is:pr" in q and "created:" in q: return { "data": { @@ -309,3 +400,4 @@ async def override_conn(): opened = r.json()["opened_pull_requests"] numbers = {p["number"] for p in opened} assert numbers == {20, 21} + assert r.json()["pr_backlog"] == [] diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index 8ffe601..db7c177 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -56,6 +56,7 @@ describe('App', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + pr_backlog: [], }); }); diff --git a/frontend/src/components/ProjectStatusPanel.jsx b/frontend/src/components/ProjectStatusPanel.jsx index 0abe3bc..243d4e4 100644 --- a/frontend/src/components/ProjectStatusPanel.jsx +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -285,6 +285,7 @@ const ProjectStatusPanel = () => { heading="New issues opened" items={report.opened_issues} /> + )} diff --git a/frontend/src/components/ProjectStatusPanel.test.jsx b/frontend/src/components/ProjectStatusPanel.test.jsx index fac70e2..71a95c6 100644 --- a/frontend/src/components/ProjectStatusPanel.test.jsx +++ b/frontend/src/components/ProjectStatusPanel.test.jsx @@ -42,6 +42,7 @@ describe('ProjectStatusPanel', () => { merged_pull_requests: merged, opened_pull_requests: [], opened_issues: issues, + pr_backlog: [], }); }); @@ -82,6 +83,9 @@ describe('ProjectStatusPanel', () => { name: 'Copy Merged pull requests to clipboard', }) ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'PR backlog' }) + ).toBeInTheDocument(); }); await user.click( @@ -107,6 +111,7 @@ describe('ProjectStatusPanel', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + pr_backlog: [], }); render(); diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 9bbce09..0bcfb59 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -179,6 +179,7 @@ describe('api', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + pr_backlog: [], }; global.fetch.mockResolvedValue({ ok: true, json: async () => body }); const result = await fetchProjectStatusReport('2025-04-04', '2025-04-10'); @@ -197,6 +198,7 @@ describe('api', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + pr_backlog: [], }), }); await fetchProjectStatusReport('', ''); From e67fa30eece2c9ca53143375c3f3d0200f8fab03 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Tue, 12 May 2026 10:40:22 -0400 Subject: [PATCH 2/4] Report "PR backlog" on status page This includes inactive older PRs. Assisted-by: Cursor Signed-off-by: David Butenhof --- backend/src/github_pm/status_report_models.py | 14 +++++++- .../src/github_pm/status_report_service.py | 27 +++++++++++++-- backend/tests/test_status_report_api.py | 9 ++++- .../src/components/ProjectStatusPanel.jsx | 26 +++++++++++++-- .../components/ProjectStatusPanel.test.jsx | 23 +++++++++++++ frontend/src/utils/clipboard.js | 29 ++++++++++++---- frontend/src/utils/clipboard.test.js | 33 +++++++++++++++++++ 7 files changed, 147 insertions(+), 14 deletions(-) diff --git a/backend/src/github_pm/status_report_models.py b/backend/src/github_pm/status_report_models.py index 9a4dfb1..73317fb 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,7 +47,7 @@ class ProjectStatusReportResponse(BaseModel): default_factory=list, description="Issues created in the window (pull requests excluded)", ) - pr_backlog: list[StatusReportItem] = Field( + pr_backlog: list[PrBacklogItem] = Field( default_factory=list, description=( "Open, non-draft pull requests whose last update (UTC) is strictly before " diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py index 58fbe56..d9c78b5 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -10,7 +10,11 @@ from github_pm import sdlc_metrics as sm from github_pm.api import Connector -from github_pm.status_report_models import ProjectStatusReportResponse, StatusReportItem +from github_pm.status_report_models import ( + PrBacklogItem, + ProjectStatusReportResponse, + StatusReportItem, +) def _item_from_gql_node(node: dict[str, Any]) -> StatusReportItem: @@ -49,6 +53,25 @@ def _updated_strictly_before_start_date(node: dict[str, Any], start_d: date) -> 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 build_project_status_report( gitctx: Connector, *, @@ -111,7 +134,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: and _updated_strictly_before_start_date(n, start_date) ] backlog_filtered.sort(key=lambda n: int(n["number"])) - backlog_items = [_item_from_gql_node(n) for n in backlog_filtered] + backlog_items = [_backlog_item_from_gql_node(n, end_date) for n in backlog_filtered] return ProjectStatusReportResponse( start_date=start_date, diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py index 2260183..c14f536 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -300,7 +300,14 @@ async def override_conn(): assert r.status_code == 200 backlog = r.json()["pr_backlog"] - assert [p["number"] for p in backlog] == [50] + 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.""" diff --git a/frontend/src/components/ProjectStatusPanel.jsx b/frontend/src/components/ProjectStatusPanel.jsx index 243d4e4..1a42510 100644 --- a/frontend/src/components/ProjectStatusPanel.jsx +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -50,7 +50,20 @@ 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, items, renderTitleSuffix }) => { const hasItems = items && items.length > 0; const copyLines = () => { if (!hasItems) { @@ -101,7 +114,10 @@ const StatusSection = ({ heading, items }) => { > #{row.number} - {row.title} + + {row.title} + {renderTitleSuffix ? renderTitleSuffix(row) : null} + ))} @@ -285,7 +301,11 @@ const ProjectStatusPanel = () => { heading="New issues opened" items={report.opened_issues} /> - + )} diff --git a/frontend/src/components/ProjectStatusPanel.test.jsx b/frontend/src/components/ProjectStatusPanel.test.jsx index 71a95c6..25a33ef 100644 --- a/frontend/src/components/ProjectStatusPanel.test.jsx +++ b/frontend/src/components/ProjectStatusPanel.test.jsx @@ -73,6 +73,29 @@ 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: [], + 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(); 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', () => { From caf4824528e1704b1deaeb47c6c30e770e5a18aa Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Tue, 12 May 2026 14:57:56 -0400 Subject: [PATCH 3/4] Recently updated (old) PRs Add a section to report "older" PRs (created before reporting window) which have been updated within the reporting window. Signed-off-by: David Butenhof --- backend/src/github_pm/sdlc_metrics.py | 11 ++ backend/src/github_pm/status_report_api.py | 5 +- backend/src/github_pm/status_report_models.py | 7 ++ .../src/github_pm/status_report_service.py | 29 +++++ backend/tests/test_status_report_api.py | 101 +++++++++++++++++- frontend/src/App.test.jsx | 1 + .../src/components/ProjectStatusPanel.jsx | 4 + .../components/ProjectStatusPanel.test.jsx | 6 ++ frontend/src/services/api.test.js | 2 + 9 files changed, 163 insertions(+), 3 deletions(-) diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index f814c83..bbd3e47 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -509,6 +509,17 @@ def open_pr_backlog_query(github_repo: str, start_d: date) -> str: return f"{repo_search_fragment(github_repo)} is:pr is:open draft:false updated:<{d}" +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 a9b7cf7..dae0c29 100644 --- a/backend/src/github_pm/status_report_api.py +++ b/backend/src/github_pm/status_report_api.py @@ -45,8 +45,9 @@ 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), and PR backlog (open, non-draft PRs not updated on or - after ``start_date``, UTC calendar dates). + opened (PRs excluded), recently updated PRs (open, non-draft, touched in the + window but not opened in it), 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 73317fb..696e720 100644 --- a/backend/src/github_pm/status_report_models.py +++ b/backend/src/github_pm/status_report_models.py @@ -47,6 +47,13 @@ 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)" + ), + ) pr_backlog: list[PrBacklogItem] = Field( default_factory=list, description=( diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py index d9c78b5..1cd2a5f 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -45,6 +45,16 @@ 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")) @@ -119,6 +129,24 @@ 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] + backlog_q = sm.open_pr_backlog_query(repo, start_date) backlog_nodes = sm.graphql_search_pull_requests( post_gql, @@ -142,5 +170,6 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: 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, pr_backlog=backlog_items, ) diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py index c14f536..2decea8 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -23,6 +23,7 @@ def mock_connector_graphql(): gitctx.owner = "test" gitctx.repo = "repo" gitctx.status_backlog_nodes = [] + gitctx.status_recently_updated_nodes = [] merged_nodes = [ { @@ -84,11 +85,20 @@ def post_side(path: str, data=None, **kwargs): } } 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_backlog_nodes), + "nodes": list(gitctx.status_recently_updated_nodes), } } } @@ -133,6 +143,7 @@ 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["pr_backlog"] == [] def test_report_single_calendar_day(self, client, mock_connector_graphql): @@ -234,6 +245,84 @@ async def override_conn(): "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 + ) + + 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 @@ -370,6 +459,15 @@ def post_side(path: str, data=None, **kwargs): } } 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": { @@ -407,4 +505,5 @@ 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()["pr_backlog"] == [] diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index db7c177..779c732 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -56,6 +56,7 @@ describe('App', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], pr_backlog: [], }); }); diff --git a/frontend/src/components/ProjectStatusPanel.jsx b/frontend/src/components/ProjectStatusPanel.jsx index 1a42510..c87a33d 100644 --- a/frontend/src/components/ProjectStatusPanel.jsx +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -301,6 +301,10 @@ const ProjectStatusPanel = () => { heading="New issues opened" items={report.opened_issues} /> + { merged_pull_requests: merged, opened_pull_requests: [], opened_issues: issues, + recently_updated_pull_requests: [], pr_backlog: [], }); }); @@ -80,6 +81,7 @@ describe('ProjectStatusPanel', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], pr_backlog: [ { number: 50, @@ -106,6 +108,9 @@ describe('ProjectStatusPanel', () => { name: 'Copy Merged pull requests to clipboard', }) ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Recently updated PRs' }) + ).toBeInTheDocument(); expect( screen.getByRole('heading', { name: 'PR backlog' }) ).toBeInTheDocument(); @@ -134,6 +139,7 @@ describe('ProjectStatusPanel', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], pr_backlog: [], }); diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 0bcfb59..6fbccc4 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -179,6 +179,7 @@ describe('api', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], pr_backlog: [], }; global.fetch.mockResolvedValue({ ok: true, json: async () => body }); @@ -198,6 +199,7 @@ describe('api', () => { merged_pull_requests: [], opened_pull_requests: [], opened_issues: [], + recently_updated_pull_requests: [], pr_backlog: [], }), }); From f5ccc251548f0cb3ce41a14abf7bced04ff8b7d8 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Wed, 13 May 2026 13:16:27 -0400 Subject: [PATCH 4/4] Break down "backlog" Report on older PRs that need reviewer or creator attention. Generated-by: Cursor Signed-off-by: David Butenhof --- backend/src/github_pm/sdlc_metrics.py | 86 ++++++++++ backend/src/github_pm/status_report_api.py | 5 +- backend/src/github_pm/status_report_models.py | 16 ++ .../src/github_pm/status_report_service.py | 64 +++++++- backend/tests/test_status_report_api.py | 151 ++++++++++++++++++ frontend/src/App.test.jsx | 2 + .../src/components/ProjectStatusPanel.jsx | 86 ++++++++-- .../components/ProjectStatusPanel.test.jsx | 12 ++ frontend/src/services/api.test.js | 4 + 9 files changed, 406 insertions(+), 20 deletions(-) diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index bbd3e47..054cfc1 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -406,6 +406,87 @@ def graphql_search_pull_requests( 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 } } + 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_timeline_nodes( post_graphql: Callable[[dict[str, Any]], dict[str, Any]], search_query: str, @@ -509,6 +590,11 @@ def open_pr_backlog_query(github_repo: str, start_d: date) -> str: return f"{repo_search_fragment(github_repo)} is:pr is:open draft:false updated:<{d}" +def open_pull_requests_for_attention_query(github_repo: str) -> 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() diff --git a/backend/src/github_pm/status_report_api.py b/backend/src/github_pm/status_report_api.py index dae0c29..a2ee89d 100644 --- a/backend/src/github_pm/status_report_api.py +++ b/backend/src/github_pm/status_report_api.py @@ -46,8 +46,9 @@ async def get_project_status_report( 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), and PR backlog (open, non-draft PRs not updated - on or after ``start_date``, UTC calendar dates). + 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 696e720..4d85fe3 100644 --- a/backend/src/github_pm/status_report_models.py +++ b/backend/src/github_pm/status_report_models.py @@ -54,6 +54,22 @@ class ProjectStatusReportResponse(BaseModel): "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=( diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py index 1cd2a5f..0256322 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -5,7 +5,7 @@ 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 @@ -82,6 +82,56 @@ def _backlog_item_from_gql_node(node: dict[str, Any], end_d: date) -> PrBacklogI ) +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, *, @@ -147,6 +197,16 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: 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, @@ -171,5 +231,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: 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 2decea8..b0ed3a2 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -24,6 +24,7 @@ def mock_connector_graphql(): gitctx.repo = "repo" gitctx.status_backlog_nodes = [] gitctx.status_recently_updated_nodes = [] + gitctx.status_attention_open_nodes = [] merged_nodes = [ { @@ -84,6 +85,15 @@ 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 { @@ -144,6 +154,8 @@ 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): @@ -250,6 +262,134 @@ async def override_conn(): 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 @@ -458,6 +598,15 @@ 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 { @@ -506,4 +655,6 @@ async def override_conn(): 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 779c732..af0100e 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -57,6 +57,8 @@ describe('App', () => { 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 c87a33d..f009c70 100644 --- a/frontend/src/components/ProjectStatusPanel.jsx +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -57,13 +57,24 @@ const backlogAgeSuffix = (row) => { } const unit = n === 1 ? 'day' : 'days'; return ( - + ({n} {unit}) ); }; -const StatusSection = ({ heading, items, renderTitleSuffix }) => { +const StatusSection = ({ + heading, + description, + items, + renderTitleSuffix, + emptyMessage, +}) => { const hasItems = items && items.length > 0; const copyLines = () => { if (!hasItems) { @@ -72,34 +83,58 @@ const StatusSection = ({ heading, items, renderTitleSuffix }) => { void copyStatusSectionToClipboard(items); }; + const emptyText = emptyMessage ?? emptyListMessage; + return (
-
+ {description ? ( +

+ {description} +

+ ) : null}
{!items || items.length === 0 ? ( - {emptyListMessage} + {emptyText} ) : (
    {items.map((row) => ( @@ -291,22 +326,39 @@ const ProjectStatusPanel = () => { <> + + diff --git a/frontend/src/components/ProjectStatusPanel.test.jsx b/frontend/src/components/ProjectStatusPanel.test.jsx index 303ae01..9b379ba 100644 --- a/frontend/src/components/ProjectStatusPanel.test.jsx +++ b/frontend/src/components/ProjectStatusPanel.test.jsx @@ -43,6 +43,8 @@ describe('ProjectStatusPanel', () => { opened_pull_requests: [], opened_issues: issues, recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], pr_backlog: [], }); }); @@ -82,6 +84,8 @@ describe('ProjectStatusPanel', () => { opened_pull_requests: [], opened_issues: [], recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], pr_backlog: [ { number: 50, @@ -111,6 +115,12 @@ describe('ProjectStatusPanel', () => { 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(); @@ -140,6 +150,8 @@ describe('ProjectStatusPanel', () => { opened_pull_requests: [], opened_issues: [], recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], pr_backlog: [], }); diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 6fbccc4..d384dc1 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -180,6 +180,8 @@ describe('api', () => { 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 }); @@ -200,6 +202,8 @@ describe('api', () => { opened_pull_requests: [], opened_issues: [], recently_updated_pull_requests: [], + reviewer_attention_needed: [], + creator_attention_needed: [], pr_backlog: [], }), });