diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 4b94aed..0a5b935 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -36,6 +36,8 @@ Optional environment variables: |------|-------------| | `/` | Home / operator overview | | `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) | +| `/queue` | Live PR and issue queue dashboard (#429) | +| `/api/queue` | JSON queue export with pagination metadata | | `/projects` | Project registry list (#427) | | `/projects/{id}` | Project detail + onboarding checklist | | `/api/projects` | JSON registry export | @@ -69,8 +71,19 @@ Prompts are generated at load time from canonical workflow files under copy/paste starters; canonical workflow files remain the only full policy source. +## Live queue dashboard (#429) + +`/queue` loads open PRs and issues for the default registry project (seed: +**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read +credentials. The UI surfaces pagination proof (returned count, pages fetched, +`has_more`, `inventory_complete`) and classification badges (`claimed`, +`blocked`, `in-review`, `duplicate`) when evidence exists. + +If credentials are missing or the fetch fails, the page shows an explicit error +instead of an empty queue (fail closed). + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_queue_dashboard.py b/tests/test_webui_queue_dashboard.py new file mode 100644 index 0000000..f4671ac --- /dev/null +++ b/tests/test_webui_queue_dashboard.py @@ -0,0 +1,138 @@ +"""Tests for web UI live queue dashboard (#429).""" +import json +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.queue_loader import ( + PaginationMeta, + load_queue_snapshot, + snapshot_to_dict, +) + + +def _page(items: list[dict], *, final: bool = True) -> tuple[list[dict], PaginationMeta]: + return items, PaginationMeta( + page=1, + per_page=50, + returned_count=len(items), + has_more=not final, + is_final_page=final, + inventory_complete=final, + pages_fetched=1, + ) + + +SAMPLE_PRS = [ + { + "number": 10, + "title": "feat: example", + "mergeable": True, + "body": "Closes #5", + "head": {"ref": "feat/x", "sha": "abc123def456"}, + "base": {"ref": "master"}, + }, + { + "number": 9, + "title": "fix: conflict", + "mergeable": False, + "body": "Closes #5", + "head": {"ref": "feat/y", "sha": "deadbeef0000"}, + "base": {"ref": "master"}, + }, +] + +SAMPLE_ISSUES = [ + { + "number": 5, + "title": "Tracked issue", + "state": "open", + "labels": [{"name": "status:in-progress"}], + "assignee": {"login": "jcwalker3"}, + }, + { + "number": 4, + "title": "Idle issue", + "state": "open", + "labels": [], + "assignee": None, + }, +] + + +class TestQueueLoader(unittest.TestCase): + def test_classifies_claimed_duplicate_and_blocked(self): + snapshot = load_queue_snapshot( + fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS), + fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES), + ) + self.assertIsNone(snapshot.fetch_error) + pr_badges = {p.number: p.badges for p in snapshot.prs} + self.assertIn("blocked", pr_badges[9]) + self.assertIn("duplicate", pr_badges[9]) + issue_badges = {i.number: i.badges for i in snapshot.issues} + self.assertIn("claimed", issue_badges[5]) + self.assertIn("duplicate", issue_badges[5]) + + def test_fail_closed_without_credentials(self): + with patch("webui.queue_loader.get_auth_header", return_value=None): + snapshot = load_queue_snapshot() + self.assertIsNotNone(snapshot.fetch_error) + self.assertIn("credentials unavailable", snapshot.fetch_error.lower()) + self.assertEqual(snapshot.prs, ()) + self.assertEqual(snapshot.issues, ()) + + def test_snapshot_json_includes_pagination(self): + snapshot = load_queue_snapshot( + fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS, final=False), + fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES), + ) + payload = snapshot_to_dict(snapshot) + self.assertFalse(payload["pagination"]["prs"]["inventory_complete"]) + self.assertTrue(payload["pagination"]["prs"]["has_more"]) + self.assertTrue(payload["pagination"]["issues"]["inventory_complete"]) + + +class TestQueueRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_queue_page_renders_with_mocked_loader(self): + snapshot = load_queue_snapshot( + fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS), + fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES), + ) + with patch("webui.app.load_queue_snapshot", return_value=snapshot): + response = self.client.get("/queue") + self.assertEqual(response.status_code, 200) + self.assertIn("Live queue", response.text) + self.assertIn("feat: example", response.text) + self.assertIn("Tracked issue", response.text) + self.assertIn("pagination", response.text.lower()) + + def test_api_queue_json(self): + snapshot = load_queue_snapshot( + fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS), + fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES), + ) + with patch("webui.app.load_queue_snapshot", return_value=snapshot): + response = self.client.get("/api/queue") + self.assertEqual(response.status_code, 200) + payload = json.loads(response.text) + self.assertEqual(len(payload["prs"]), 2) + self.assertEqual(len(payload["issues"]), 2) + + def test_nav_includes_queue_link(self): + response = self.client.get("/") + self.assertEqual(response.status_code, 200) + self.assertIn('href="/queue"', response.text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/webui/app.py b/webui/app.py index d1e1c38..a97d1ad 100644 --- a/webui/app.py +++ b/webui/app.py @@ -14,6 +14,8 @@ from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page +from webui.queue_loader import load_queue_snapshot, snapshot_to_dict +from webui.queue_views import render_queue_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -32,6 +34,7 @@ async def home(_request: Request) -> HTMLResponse: "

Operator console

" "

Local entry point for MCP Control Plane operational views.

" "