diff --git a/.gitignore b/.gitignore
index 54e858c2..b6e5431f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,7 +84,9 @@ Thumbs.db
.codeframe/
.agent-tasks/
# Local planning scratch (issue-lifecycle todo.md, migration notes) — not source.
-tasks/
+# Anchored to repo root so it does NOT also ignore nested source dirs such as
+# web-ui/src/components/tasks/.
+/tasks/
# TestSprite Specific
testsprite_tests/tmp/config.json
diff --git a/CLAUDE.md b/CLAUDE.md
index 5fad4bb2..4828ae2c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,9 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol
### Current Focus: Phase 4A
-**Phase 5.5 is in progress** — GitHub Issues import. Repo connection via PAT (#563) is **complete**: Settings → **Integrations** tab connects a GitHub repo with a Personal Access Token. Backend `POST/DELETE/GET /api/v2/integrations/github/{connect,disconnect,status}` (`ui/routers/github_integrations_v2.py`). Validation is headless in `core/github_connect_service.py` (httpx; verifies token, repo visibility, and issues-read access; typed errors → 401/404/403). The PAT is stored machine-wide via `CredentialManager` (`CredentialProvider.GIT_GITHUB`, the #555 pattern) and **never returned in any response**; non-secret repo metadata persists per-workspace in `.codeframe/github_integration.json` (`core/github_integration_config.py`). Frontend: `GitHubIntegrationCard` + `integrationsApi`. Issue browse + import (#564–565) are the remaining 5.5 work.
+**Phase 5.5 is in progress** — GitHub Issues import. Repo connection via PAT (#563) is **complete**: Settings → **Integrations** tab connects a GitHub repo with a Personal Access Token. Backend `POST/DELETE/GET /api/v2/integrations/github/{connect,disconnect,status}` (`ui/routers/github_integrations_v2.py`). Validation is headless in `core/github_connect_service.py` (httpx; verifies token, repo visibility, and issues-read access; typed errors → 401/404/403). The PAT is stored machine-wide via `CredentialManager` (`CredentialProvider.GIT_GITHUB`, the #555 pattern) and **never returned in any response**; non-secret repo metadata persists per-workspace in `.codeframe/github_integration.json` (`core/github_integration_config.py`). Frontend: `GitHubIntegrationCard` + `integrationsApi`.
+
+Issue **browse** (#564) is **complete**: `GET /api/v2/integrations/github/issues?page&per_page&search&label` on the same router lists the connected repo's **open** issues (PRs filtered out) — repo from `.codeframe/github_integration.json`, PAT from `CredentialManager`, **409** when not connected. Headless fetch in `core/github_issues_service.py` (`list_issues`): plain `/repos/{o}/{r}/issues` by default, routes to `/search/issues` for free-text search, `labels=` filter, `Link`-header pagination, 60s in-process TTL cache, typed errors → 401/403/502. Frontend: `GitHubIssueImportModal` (paginated list, debounced search, label filter, multi-select that persists across pages, select-all-on-page, Import-Selected gated on ≥1) + `integrationsApi.getIssues`; an **Import from GitHub** button on `/tasks` (`TaskBoardView`) shown only when connected. The import action itself is the remaining 5.5 work (#565).
**Phase 5.4 is complete** — PRD stress-test web UI: trigger + streaming (#561). Backend: `GET /api/v2/prd/stress-test` SSE endpoint streams `goals_extracted`, `goal_analyzed`, `complete`, and `error` events from `core/prd_stress_test.py:stress_test_prd_stream()`, resolving the LLM provider via the standard chain and applying the standard rate limit. Frontend: `useStressTestStream` hook manages the SSE connection and event accumulation; `StressTestModal` renders the streaming progress and is opened via a "Stress Test" button on the `/prd` page (enabled only when a PRD exists). Results rendering + refinement (#562) is **complete**: the `complete` SSE event now carries structured, severity-tagged `ambiguities` (`Ambiguity.severity` is `"blocking"`/`"warning"`); `StressTestModal` shows a results view of `AmbiguityCard`s (question text, severity badge, answer textarea) with an "X of Y answered" progress indicator and a **[Refine PRD]** button (disabled until every blocking ambiguity is answered). Refine posts to `POST /api/v2/prd/stress-test/refine`, which folds the answers into a new PRD version via `resolve_ambiguities_into_prd` (offloaded with `asyncio.to_thread`) and `prd.create_new_version`, then `mutatePrd` reflects it in the editor.
diff --git a/codeframe/core/github_issues_service.py b/codeframe/core/github_issues_service.py
new file mode 100644
index 00000000..ec26ff2a
--- /dev/null
+++ b/codeframe/core/github_issues_service.py
@@ -0,0 +1,231 @@
+"""GitHub open-issues listing service (issue #564).
+
+Headless service used by the Integrations issues endpoint to fetch a connected
+repository's **open** issues for the import browser UI. Builds on the connection
+established in #563: the PAT comes from the machine-wide ``CredentialManager``
+and the ``owner/repo`` from per-workspace ``.codeframe/github_integration.json``
+— this module only performs the GitHub API call given those values.
+
+No FastAPI / HTTP-framework imports (architecture rule #1 — core is headless).
+Reuses the shared helpers and typed errors from ``github_connect_service``.
+
+Search note: GitHub's REST *list* endpoint (``/repos/{o}/{r}/issues``) does not
+support free-text search, so when a ``search`` term is supplied this routes to
+``/search/issues`` with a ``repo:`` + ``is:issue`` + ``is:open`` qualifier and
+reads the authoritative ``total_count``. The plain list endpoint also returns
+pull requests, which are filtered out here.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Optional, TypedDict
+
+import httpx
+
+from codeframe.core.github_connect_service import (
+ GITHUB_API_BASE,
+ GitHubConnectError,
+ InsufficientScopeError,
+ InvalidTokenError,
+ _headers,
+ parse_repo,
+)
+
+logger = logging.getLogger(__name__)
+
+_TIMEOUT = 15.0
+
+# Parse the ``page=N`` query param out of a Link header's rel="last" URL.
+_LAST_PAGE_RE = re.compile(r'[?&]page=(\d+)[^>]*>;\s*rel="last"')
+
+
+class GitHubIssue(TypedDict):
+ number: int
+ title: str
+ labels: list[str]
+ assignee: Optional[str]
+ created_at: str
+ html_url: str
+
+
+def _simplify(raw: dict) -> GitHubIssue:
+ labels_raw = raw.get("labels") or []
+ labels = [
+ (lbl.get("name") if isinstance(lbl, dict) else str(lbl))
+ for lbl in labels_raw
+ ]
+ labels = [n for n in labels if n]
+ assignee_raw = raw.get("assignee") or None
+ assignee = assignee_raw.get("login") if isinstance(assignee_raw, dict) else None
+ return {
+ "number": int(raw.get("number", 0)),
+ "title": str(raw.get("title") or ""),
+ "labels": labels,
+ "assignee": assignee,
+ "created_at": str(raw.get("created_at") or ""),
+ "html_url": str(raw.get("html_url") or ""),
+ }
+
+
+def _raise_for_status(status_code: int, *, context: str) -> None:
+ """Map a GitHub HTTP status to a typed error. 2xx/410 are handled by callers."""
+ if status_code == 401:
+ raise InvalidTokenError("Invalid GitHub token.")
+ if status_code == 403:
+ raise InsufficientScopeError(
+ "Token cannot read issues for this repository "
+ "(missing issues:read scope)."
+ )
+ if status_code >= 400:
+ raise GitHubConnectError(
+ f"GitHub {context} returned status {status_code}."
+ )
+
+
+def _total_from_link_header(link: Optional[str], items_len: int, per_page: int) -> int:
+ """Estimate total issue count from the ``Link`` header's rel="last" page.
+
+ GitHub does not return an exact count on the list endpoint; the last-page
+ number times ``per_page`` is the standard upper-bound estimate used for
+ pagination controls. Falls back to ``items_len`` when there is no next page.
+ """
+ if link:
+ match = _LAST_PAGE_RE.search(link)
+ if match:
+ return int(match.group(1)) * per_page
+ return items_len
+
+
+async def list_issues(
+ pat: str,
+ repo: str,
+ *,
+ page: int = 1,
+ per_page: int = 25,
+ search: str = "",
+ label: str = "",
+ client: Optional[httpx.AsyncClient] = None,
+) -> tuple[list[GitHubIssue], int]:
+ """List **open** issues for ``repo``, optionally filtered by search/label.
+
+ Args:
+ pat: GitHub Personal Access Token.
+ repo: Repository in ``owner/repo`` format.
+ page: 1-indexed page number.
+ per_page: Page size (caller should clamp to GitHub's 1..100 range).
+ search: Free-text title/body search (routes to the search API).
+ label: Single label name to filter by.
+ client: Optional httpx client (injected by tests). When ``None`` a
+ short-lived client is created and closed internally.
+
+ Returns:
+ ``(issues, total)`` where ``issues`` is a list of simplified open issues
+ (pull requests excluded) and ``total`` is the best-available count for
+ pagination.
+
+ Raises:
+ ValueError: if ``repo`` is not a valid ``owner/repo`` string.
+ InvalidTokenError: GitHub returned 401.
+ InsufficientScopeError: the token cannot read issues (403).
+ GitHubConnectError: any other non-success response or network error.
+ """
+ owner, name = parse_repo(repo)
+
+ own_client = client is None
+ if own_client:
+ client = httpx.AsyncClient(timeout=_TIMEOUT)
+ try:
+ headers = _headers(pat)
+ if search.strip():
+ return await _search_issues(
+ client, headers, owner, name, page, per_page, search, label
+ )
+ return await _list_issues(
+ client, headers, owner, name, page, per_page, label
+ )
+ finally:
+ if own_client:
+ await client.aclose()
+
+
+async def _list_issues(
+ client: httpx.AsyncClient,
+ headers: dict[str, str],
+ owner: str,
+ name: str,
+ page: int,
+ per_page: int,
+ label: str,
+) -> tuple[list[GitHubIssue], int]:
+ params: dict[str, object] = {
+ "state": "open",
+ "page": page,
+ "per_page": per_page,
+ }
+ if label.strip():
+ params["labels"] = label.strip()
+ try:
+ resp = await client.get(
+ f"{GITHUB_API_BASE}/repos/{owner}/{name}/issues",
+ params=params,
+ headers=headers,
+ )
+ except httpx.HTTPError as exc:
+ logger.warning("GitHub issues list failed: %s", type(exc).__name__)
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
+
+ # 410 Gone == issues disabled on the repo: nothing to import, not an error.
+ if resp.status_code == 410:
+ return [], 0
+ _raise_for_status(resp.status_code, context="issues list")
+
+ raw_items = resp.json()
+ if not isinstance(raw_items, list):
+ raw_items = []
+ # The /issues endpoint includes pull requests — drop them.
+ issues = [_simplify(it) for it in raw_items if "pull_request" not in it]
+ total = _total_from_link_header(resp.headers.get("Link"), len(issues), per_page)
+ return issues, total
+
+
+async def _search_issues(
+ client: httpx.AsyncClient,
+ headers: dict[str, str],
+ owner: str,
+ name: str,
+ page: int,
+ per_page: int,
+ search: str,
+ label: str,
+) -> tuple[list[GitHubIssue], int]:
+ qualifiers = [
+ search.strip(),
+ f"repo:{owner}/{name}",
+ "is:issue",
+ "is:open",
+ ]
+ if label.strip():
+ qualifiers.append(f'label:"{label.strip()}"')
+ q = " ".join(qualifiers)
+ try:
+ resp = await client.get(
+ f"{GITHUB_API_BASE}/search/issues",
+ params={"q": q, "page": page, "per_page": per_page},
+ headers=headers,
+ )
+ except httpx.HTTPError as exc:
+ logger.warning("GitHub issues search failed: %s", type(exc).__name__)
+ raise GitHubConnectError("Could not reach GitHub. Try again later.")
+
+ _raise_for_status(resp.status_code, context="issues search")
+
+ data = resp.json()
+ if not isinstance(data, dict):
+ data = {}
+ raw_items = data.get("items") or []
+ # The search API can still surface PRs if the qualifier is loosened; guard.
+ issues = [_simplify(it) for it in raw_items if "pull_request" not in it]
+ total = int(data.get("total_count", len(issues)))
+ return issues, total
diff --git a/codeframe/ui/routers/github_integrations_v2.py b/codeframe/ui/routers/github_integrations_v2.py
index 2ec99be2..1bf52752 100644
--- a/codeframe/ui/routers/github_integrations_v2.py
+++ b/codeframe/ui/routers/github_integrations_v2.py
@@ -4,6 +4,7 @@
POST /connect - Validate PAT against repo, store PAT, save repo metadata
DELETE /disconnect - Clear stored PAT + repo metadata
GET /status - Report connection status (never exposes the PAT)
+ GET /issues - List the connected repo's open issues (#564)
The PAT is stored machine-wide via ``CredentialManager`` under
``CredentialProvider.GIT_GITHUB`` — the same slot the API Keys settings tab
@@ -13,9 +14,10 @@
"""
import logging
-from typing import Optional
+import time
+from typing import Any, Optional
-from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from codeframe.core.credentials import CredentialManager, CredentialProvider
@@ -27,6 +29,7 @@
parse_repo,
validate_connection,
)
+from codeframe.core.github_issues_service import list_issues
from codeframe.core.github_integration_config import (
clear_github_integration_config,
load_github_integration_config,
@@ -69,6 +72,45 @@ class StatusResponse(BaseModel):
owner_avatar_url: Optional[str] = None
+class GitHubIssueItem(BaseModel):
+ number: int
+ title: str
+ labels: list[str]
+ assignee: Optional[str] = None
+ created_at: str
+ html_url: str
+
+
+class GitHubIssuesResponse(BaseModel):
+ issues: list[GitHubIssueItem]
+ total: int
+ page: int
+ per_page: int
+
+
+# In-process TTL cache for issue listings (#564). Keyed by the full query
+# (repo + page + per_page + search + label); entries expire after 60s to avoid
+# hammering GitHub's rate limit on repeated browses. Module-level so it is
+# shared across requests within the same server process.
+_ISSUE_CACHE_TTL_SECONDS = 60.0
+_ISSUE_CACHE: dict[str, tuple[float, Any]] = {}
+
+
+def _issue_cache_get(key: str) -> Optional[Any]:
+ entry = _ISSUE_CACHE.get(key)
+ if entry is None:
+ return None
+ expires_at, payload = entry
+ if time.monotonic() >= expires_at:
+ _ISSUE_CACHE.pop(key, None)
+ return None
+ return payload
+
+
+def _issue_cache_set(key: str, payload: Any) -> None:
+ _ISSUE_CACHE[key] = (time.monotonic() + _ISSUE_CACHE_TTL_SECONDS, payload)
+
+
@router.get("/status", response_model=StatusResponse)
@rate_limit_standard()
async def get_status(
@@ -216,3 +258,83 @@ async def disconnect(
if "no such" not in msg and "not found" not in msg:
logger.warning("Failed to delete GitHub PAT on disconnect: %s", e)
return Response(status_code=204)
+
+
+@router.get("/issues", response_model=GitHubIssuesResponse)
+@rate_limit_standard()
+async def get_issues(
+ request: Request,
+ page: int = Query(1, ge=1, description="1-indexed page number"),
+ per_page: int = Query(25, description="Page size (clamped to 1..100)"),
+ search: str = Query("", description="Free-text title/body search"),
+ label: str = Query("", description="Filter by a single label name"),
+ workspace: Workspace = Depends(get_v2_workspace),
+ manager: CredentialManager = Depends(get_credential_manager),
+) -> GitHubIssuesResponse:
+ """List the connected repository's **open** issues for the import browser.
+
+ Requires an established connection (#563): repo metadata in
+ ``.codeframe/github_integration.json`` AND a stored GitHub PAT. Responses
+ are cached in-process for 60s keyed by the full query to avoid GitHub
+ rate-limit pressure during paging/searching. The PAT is never returned.
+ """
+ cfg = load_github_integration_config(workspace)
+ pat = manager.get_credential(CredentialProvider.GIT_GITHUB)
+ if cfg is None or not pat:
+ raise HTTPException(
+ status_code=409,
+ detail=api_error(
+ "No GitHub repository is connected. Connect one in Settings → "
+ "Integrations first.",
+ ErrorCodes.CONFLICT,
+ ),
+ )
+
+ # GitHub caps per_page at 100; clamp to a sane 1..100 window.
+ per_page = max(1, min(per_page, 100))
+ repo = cfg["repo"]
+
+ cache_key = f"{repo}|{page}|{per_page}|{search}|{label}"
+ cached = _issue_cache_get(cache_key)
+ if cached is not None:
+ return cached
+
+ try:
+ issues, total = await list_issues(
+ pat,
+ repo,
+ page=page,
+ per_page=per_page,
+ search=search,
+ label=label,
+ )
+ except ValueError as e:
+ # Stored repo metadata is malformed — surface as a conflict, not a 500.
+ raise HTTPException(
+ status_code=409,
+ detail=api_error(str(e), ErrorCodes.CONFLICT),
+ )
+ except InvalidTokenError as e:
+ raise HTTPException(
+ status_code=401,
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
+ )
+ except InsufficientScopeError as e:
+ raise HTTPException(
+ status_code=403,
+ detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
+ )
+ except GitHubConnectError as e:
+ raise HTTPException(
+ status_code=502,
+ detail=api_error(str(e), ErrorCodes.EXECUTION_FAILED),
+ )
+
+ response = GitHubIssuesResponse(
+ issues=[GitHubIssueItem(**issue) for issue in issues],
+ total=total,
+ page=page,
+ per_page=per_page,
+ )
+ _issue_cache_set(cache_key, response)
+ return response
diff --git a/pyproject.toml b/pyproject.toml
index 968e1937..39882cc4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
"sqlalchemy>=2.0.0",
"aiosqlite>=0.19.0",
"aiohttp>=3.9.0",
+ "httpx>=0.27.0",
"typer>=0.9.0",
"rich>=13.7.0",
"textual>=0.86.0",
diff --git a/tests/core/test_github_issues_service.py b/tests/core/test_github_issues_service.py
new file mode 100644
index 00000000..8265d207
--- /dev/null
+++ b/tests/core/test_github_issues_service.py
@@ -0,0 +1,225 @@
+"""Tests for the GitHub issues listing service (issue #564).
+
+Covers:
+- plain list endpoint returns simplified open issues
+- pull requests are filtered out (the /issues endpoint returns PRs too)
+- label filter is forwarded as the ``labels`` query param
+- free-text search routes to /search/issues and parses ``total_count``
+- pagination total is derived from the ``Link`` header (last page)
+- 401 -> InvalidTokenError, 403 -> InsufficientScopeError
+- 410 (issues disabled) -> empty list, no error
+
+Validated against a mocked httpx transport — no real network call is made.
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from codeframe.core.github_connect_service import (
+ InsufficientScopeError,
+ InvalidTokenError,
+)
+from codeframe.core.github_issues_service import list_issues
+
+pytestmark = pytest.mark.v2
+
+VALID_PAT = "ghp_validtoken1234567890"
+
+
+def _client(handler) -> httpx.AsyncClient:
+ return httpx.AsyncClient(transport=httpx.MockTransport(handler))
+
+
+def _issue(number, title, *, labels=None, login=None, is_pr=False):
+ data = {
+ "number": number,
+ "title": title,
+ "labels": [{"name": n} for n in (labels or [])],
+ "assignee": ({"login": login} if login else None),
+ "created_at": "2026-05-01T12:00:00Z",
+ "html_url": f"https://github.com/acme/app/issues/{number}",
+ }
+ if is_pr:
+ data["pull_request"] = {"url": "https://api.github.com/.../pulls/1"}
+ return data
+
+
+class TestListEndpoint:
+ @pytest.mark.asyncio
+ async def test_returns_simplified_open_issues(self):
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert request.url.path == "/repos/acme/app/issues"
+ assert request.url.params.get("state") == "open"
+ assert request.url.params.get("page") == "1"
+ assert request.url.params.get("per_page") == "25"
+ return httpx.Response(
+ 200,
+ json=[
+ _issue(42, "Fix login bug", labels=["bug", "auth"], login="alice"),
+ _issue(41, "Add dark mode", labels=["ui"], login=None),
+ ],
+ )
+
+ async with _client(handler) as client:
+ issues, total = await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=25, client=client
+ )
+ assert total == 2
+ assert issues[0] == {
+ "number": 42,
+ "title": "Fix login bug",
+ "labels": ["bug", "auth"],
+ "assignee": "alice",
+ "created_at": "2026-05-01T12:00:00Z",
+ "html_url": "https://github.com/acme/app/issues/42",
+ }
+ assert issues[1]["assignee"] is None
+
+ @pytest.mark.asyncio
+ async def test_filters_out_pull_requests(self):
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ json=[
+ _issue(42, "Real issue"),
+ _issue(40, "A PR masquerading as issue", is_pr=True),
+ ],
+ )
+
+ async with _client(handler) as client:
+ issues, total = await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=25, client=client
+ )
+ assert [i["number"] for i in issues] == [42]
+ assert total == 1
+
+ @pytest.mark.asyncio
+ async def test_label_filter_forwarded(self):
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["labels"] = request.url.params.get("labels")
+ return httpx.Response(200, json=[_issue(1, "x", labels=["bug"])])
+
+ async with _client(handler) as client:
+ await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=25, label="bug", client=client
+ )
+ assert seen["labels"] == "bug"
+
+ @pytest.mark.asyncio
+ async def test_total_from_link_header(self):
+ def handler(request: httpx.Request) -> httpx.Response:
+ link = (
+ '; '
+ 'rel="next", '
+ '; '
+ 'rel="last"'
+ )
+ return httpx.Response(
+ 200,
+ headers={"Link": link},
+ json=[_issue(1, "a"), _issue(2, "b")],
+ )
+
+ async with _client(handler) as client:
+ issues, total = await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=2, client=client
+ )
+ # last page 5 * per_page 2 == 10 (upper-bound estimate)
+ assert total == 10
+ assert len(issues) == 2
+
+
+class TestSearchEndpoint:
+ @pytest.mark.asyncio
+ async def test_search_routes_to_search_api(self):
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["path"] = request.url.path
+ seen["q"] = request.url.params.get("q")
+ return httpx.Response(
+ 200,
+ json={
+ "total_count": 3,
+ "items": [
+ _issue(7, "login flow", login="bob"),
+ ],
+ },
+ )
+
+ async with _client(handler) as client:
+ issues, total = await list_issues(
+ VALID_PAT,
+ "acme/app",
+ page=1,
+ per_page=25,
+ search="login",
+ client=client,
+ )
+ assert seen["path"] == "/search/issues"
+ assert "login" in seen["q"]
+ assert "repo:acme/app" in seen["q"]
+ assert "is:issue" in seen["q"]
+ assert "is:open" in seen["q"]
+ assert total == 3
+ assert issues[0]["number"] == 7
+
+ @pytest.mark.asyncio
+ async def test_search_with_label_adds_qualifier(self):
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["q"] = request.url.params.get("q")
+ return httpx.Response(200, json={"total_count": 0, "items": []})
+
+ async with _client(handler) as client:
+ await list_issues(
+ VALID_PAT,
+ "acme/app",
+ page=1,
+ per_page=25,
+ search="bug",
+ label="ui",
+ client=client,
+ )
+ assert 'label:"ui"' in seen["q"]
+
+
+class TestErrorMapping:
+ @pytest.mark.asyncio
+ async def test_401_invalid_token(self):
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(401, json={"message": "Bad credentials"})
+
+ async with _client(handler) as client:
+ with pytest.raises(InvalidTokenError):
+ await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=25, client=client
+ )
+
+ @pytest.mark.asyncio
+ async def test_403_insufficient_scope(self):
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(403, json={"message": "Forbidden"})
+
+ async with _client(handler) as client:
+ with pytest.raises(InsufficientScopeError):
+ await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=25, client=client
+ )
+
+ @pytest.mark.asyncio
+ async def test_410_issues_disabled_returns_empty(self):
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(410, json={"message": "Issues are disabled"})
+
+ async with _client(handler) as client:
+ issues, total = await list_issues(
+ VALID_PAT, "acme/app", page=1, per_page=25, client=client
+ )
+ assert issues == []
+ assert total == 0
diff --git a/tests/ui/test_github_integrations_v2.py b/tests/ui/test_github_integrations_v2.py
index c5d3971b..f633f463 100644
--- a/tests/ui/test_github_integrations_v2.py
+++ b/tests/ui/test_github_integrations_v2.py
@@ -241,3 +241,124 @@ def test_disconnect_clears_credential_and_config(
def test_disconnect_when_not_connected_is_ok(self, client):
r = client.delete("/api/v2/integrations/github/disconnect")
assert r.status_code == 204
+
+
+def _connect(client, monkeypatch, repo="acme/app"):
+ """Helper: establish a connected workspace (PAT + repo metadata stored)."""
+ _mock_validate(monkeypatch)
+ client.post(
+ "/api/v2/integrations/github/connect",
+ json={"pat": VALID_PAT, "repo": repo},
+ )
+
+
+def _mock_list_issues(monkeypatch, *, calls=None, result=None, exc=None):
+ """Patch the issues service used by the router. Records call kwargs."""
+ from codeframe.ui.routers import github_integrations_v2
+
+ async def fake(pat, repo, **kwargs):
+ if calls is not None:
+ calls.append({"pat": pat, "repo": repo, **kwargs})
+ if exc is not None:
+ raise exc
+ return result if result is not None else ([], 0)
+
+ monkeypatch.setattr(github_integrations_v2, "list_issues", fake)
+
+
+def _clear_issue_cache():
+ from codeframe.ui.routers import github_integrations_v2
+
+ github_integrations_v2._ISSUE_CACHE.clear()
+
+
+class TestListIssues:
+ def test_requires_connection(self, client, monkeypatch):
+ _clear_issue_cache()
+ # Not connected: no PAT, no repo metadata.
+ r = client.get("/api/v2/integrations/github/issues")
+ assert r.status_code == 409
+
+ def test_returns_issues_when_connected(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ sample = (
+ [
+ {
+ "number": 42,
+ "title": "Fix login bug",
+ "labels": ["bug"],
+ "assignee": "alice",
+ "created_at": "2026-05-01T12:00:00Z",
+ "html_url": "https://github.com/acme/app/issues/42",
+ }
+ ],
+ 1,
+ )
+ _mock_list_issues(monkeypatch, result=sample)
+ r = client.get("/api/v2/integrations/github/issues")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["total"] == 1
+ assert data["page"] == 1
+ assert data["per_page"] == 25
+ assert data["issues"][0]["number"] == 42
+ assert data["issues"][0]["assignee"] == "alice"
+
+ def test_forwards_pagination_and_filters(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ calls: list = []
+ _mock_list_issues(monkeypatch, calls=calls, result=([], 0))
+ client.get(
+ "/api/v2/integrations/github/issues"
+ "?page=3&per_page=10&search=login&label=bug"
+ )
+ assert calls[0]["repo"] == "acme/app"
+ assert calls[0]["page"] == 3
+ assert calls[0]["per_page"] == 10
+ assert calls[0]["search"] == "login"
+ assert calls[0]["label"] == "bug"
+
+ def test_per_page_is_clamped(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ calls: list = []
+ _mock_list_issues(monkeypatch, calls=calls, result=([], 0))
+ client.get("/api/v2/integrations/github/issues?per_page=500")
+ assert calls[0]["per_page"] == 100
+
+ def test_response_caches_within_ttl(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ calls: list = []
+ _mock_list_issues(monkeypatch, calls=calls, result=([], 0))
+ client.get("/api/v2/integrations/github/issues?page=1")
+ client.get("/api/v2/integrations/github/issues?page=1")
+ # Second identical request served from the 60s cache → no 2nd call.
+ assert len(calls) == 1
+
+ def test_invalid_token_maps_to_401(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ from codeframe.core.github_connect_service import InvalidTokenError
+
+ _mock_list_issues(monkeypatch, exc=InvalidTokenError("bad"))
+ r = client.get("/api/v2/integrations/github/issues")
+ assert r.status_code == 401
+
+ def test_insufficient_scope_maps_to_403(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ from codeframe.core.github_connect_service import InsufficientScopeError
+
+ _mock_list_issues(monkeypatch, exc=InsufficientScopeError("nope"))
+ r = client.get("/api/v2/integrations/github/issues")
+ assert r.status_code == 403
+
+ def test_pat_never_echoed(self, client, monkeypatch):
+ _clear_issue_cache()
+ _connect(client, monkeypatch)
+ _mock_list_issues(monkeypatch, result=([], 0))
+ r = client.get("/api/v2/integrations/github/issues")
+ assert VALID_PAT not in r.text
diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js
index f77cb934..84f82bd5 100644
--- a/web-ui/__mocks__/@hugeicons/react.js
+++ b/web-ui/__mocks__/@hugeicons/react.js
@@ -78,4 +78,6 @@ module.exports = {
ChartLineData01Icon: createIconMock('ChartLineData01Icon'),
// NotificationCenter
Notification02Icon: createIconMock('Notification02Icon'),
+ // GitHub issue import (issue #564)
+ GithubIcon: createIconMock('GithubIcon'),
};
diff --git a/web-ui/src/__tests__/components/tasks/GitHubIssueImportModal.test.tsx b/web-ui/src/__tests__/components/tasks/GitHubIssueImportModal.test.tsx
new file mode 100644
index 00000000..5533e24c
--- /dev/null
+++ b/web-ui/src/__tests__/components/tasks/GitHubIssueImportModal.test.tsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
+import useSWR from 'swr';
+
+import { GitHubIssueImportModal } from '@/components/tasks/GitHubIssueImportModal';
+import { integrationsApi } from '@/lib/api';
+import type { GitHubIssue, GitHubIssuesResponse } from '@/types';
+
+jest.mock('swr');
+jest.mock('@/lib/api', () => ({
+ integrationsApi: {
+ getIssues: jest.fn(),
+ },
+}));
+
+const mockUseSWR = useSWR as jest.MockedFunction;
+
+function issue(number: number, title: string, extra: Partial = {}): GitHubIssue {
+ return {
+ number,
+ title,
+ labels: extra.labels ?? [],
+ assignee: extra.assignee ?? null,
+ created_at: extra.created_at ?? '2026-05-01T12:00:00Z',
+ html_url: `https://github.com/acme/app/issues/${number}`,
+ };
+}
+
+/**
+ * Drive useSWR's return value off the key the component passes. The key is
+ * `['github-issues', workspacePath, page, search, label]`, so this lets tests
+ * return different pages for different `page` values — needed to verify that
+ * selection persists across page changes.
+ */
+function mockSWRByPage(pages: Record, total: number) {
+ mockUseSWR.mockImplementation((key) => {
+ if (!key) {
+ return { data: undefined, error: undefined, isLoading: false } as never;
+ }
+ const page = (key as unknown[])[2] as number;
+ const resp: GitHubIssuesResponse = {
+ issues: pages[page] ?? [],
+ total,
+ page,
+ per_page: 25,
+ };
+ return { data: resp, error: undefined, isLoading: false } as never;
+ });
+}
+
+const baseProps = {
+ open: true,
+ workspacePath: '/ws',
+ repo: 'acme/app',
+ onClose: jest.fn(),
+ onImport: jest.fn(),
+};
+
+describe('GitHubIssueImportModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders issue rows with title, labels, assignee', () => {
+ mockSWRByPage(
+ { 1: [issue(42, 'Fix login bug', { labels: ['bug'], assignee: 'alice' })] },
+ 1
+ );
+ render();
+ expect(screen.getByText('Fix login bug')).toBeInTheDocument();
+ expect(screen.getByText('#42')).toBeInTheDocument();
+ expect(screen.getByText('bug')).toBeInTheDocument();
+ expect(screen.getByText('@alice')).toBeInTheDocument();
+ });
+
+ it('disables Import Selected until at least one issue is selected', () => {
+ mockSWRByPage({ 1: [issue(42, 'Fix login bug')] }, 1);
+ render();
+
+ const importBtn = screen.getByRole('button', { name: /import selected/i });
+ expect(importBtn).toBeDisabled();
+
+ fireEvent.click(screen.getByLabelText('Select issue #42'));
+ expect(importBtn).toBeEnabled();
+ });
+
+ it('calls onImport with the selected issues', () => {
+ const onImport = jest.fn();
+ mockSWRByPage(
+ { 1: [issue(42, 'Fix login bug'), issue(41, 'Add dark mode')] },
+ 2
+ );
+ render();
+
+ fireEvent.click(screen.getByLabelText('Select issue #41'));
+ fireEvent.click(screen.getByRole('button', { name: /import selected/i }));
+
+ expect(onImport).toHaveBeenCalledTimes(1);
+ const passed = onImport.mock.calls[0][0] as GitHubIssue[];
+ expect(passed.map((i) => i.number)).toEqual([41]);
+ });
+
+ it('shows a selected-count badge that reflects selections', () => {
+ mockSWRByPage(
+ { 1: [issue(42, 'A'), issue(41, 'B')] },
+ 2
+ );
+ render();
+
+ expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText('Select issue #42'));
+ fireEvent.click(screen.getByLabelText('Select issue #41'));
+ expect(screen.getByText('2 selected')).toBeInTheDocument();
+ });
+
+ it('select-all-on-page selects every visible issue', () => {
+ mockSWRByPage(
+ { 1: [issue(42, 'A'), issue(41, 'B'), issue(40, 'C')] },
+ 3
+ );
+ render();
+
+ fireEvent.click(screen.getByLabelText('Select all on page'));
+ expect(screen.getByText('3 selected')).toBeInTheDocument();
+ });
+
+ it('persists selection across page changes', async () => {
+ mockSWRByPage(
+ {
+ 1: [issue(42, 'Page one issue')],
+ 2: [issue(10, 'Page two issue')],
+ },
+ 30 // > 25 so a second page exists
+ );
+ render();
+
+ // Select an issue on page 1.
+ fireEvent.click(screen.getByLabelText('Select issue #42'));
+ expect(screen.getByText('1 selected')).toBeInTheDocument();
+
+ // Go to page 2 — different rows, but the count must persist.
+ fireEvent.click(screen.getByLabelText('Next page'));
+ await waitFor(() =>
+ expect(screen.getByText('Page 2 of 2')).toBeInTheDocument()
+ );
+ expect(screen.getByText('Page two issue')).toBeInTheDocument();
+ expect(screen.getByText('1 selected')).toBeInTheDocument();
+
+ // Selecting a page-2 issue accumulates rather than replaces.
+ fireEvent.click(screen.getByLabelText('Select issue #10'));
+ expect(screen.getByText('2 selected')).toBeInTheDocument();
+ });
+
+ it('renders pagination with the right total page count', () => {
+ mockSWRByPage({ 1: [issue(1, 'x')] }, 60); // 60 / 25 = 3 pages
+ render();
+ expect(screen.getByText('Page 1 of 3')).toBeInTheDocument();
+ });
+
+ it('shows an error banner when the fetch fails', () => {
+ mockUseSWR.mockReturnValue({
+ data: undefined,
+ error: { detail: 'GitHub unreachable' },
+ isLoading: false,
+ } as never);
+ render();
+ expect(screen.getByRole('alert')).toHaveTextContent('GitHub unreachable');
+ });
+
+ it('does not fetch when closed (SWR key is null)', () => {
+ mockUseSWR.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ } as never);
+ render();
+ // SWR is called with a null key when closed.
+ const lastCallKey = mockUseSWR.mock.calls.at(-1)?.[0];
+ expect(lastCallKey).toBeNull();
+ });
+});
diff --git a/web-ui/src/__tests__/components/tasks/TaskBoardView.githubImport.test.tsx b/web-ui/src/__tests__/components/tasks/TaskBoardView.githubImport.test.tsx
new file mode 100644
index 00000000..2a38245b
--- /dev/null
+++ b/web-ui/src/__tests__/components/tasks/TaskBoardView.githubImport.test.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import useSWR from 'swr';
+
+import { TaskBoardView } from '@/components/tasks/TaskBoardView';
+import type { GitHubIntegrationStatus } from '@/types';
+
+// Focused test: the "Import from GitHub" button is gated on connection status
+// and opens the issue-import modal (issue #564). Heavy children are stubbed so
+// the test isolates the button/modal wiring.
+
+jest.mock('swr');
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: jest.fn() }),
+}));
+jest.mock('next/link', () => {
+ const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
+ {children}
+ );
+ MockLink.displayName = 'MockLink';
+ return MockLink;
+});
+jest.mock('@/hooks/useRequirementsLookup', () => ({
+ useRequirementsLookup: () => ({ requirementsMap: new Map(), isLoading: false }),
+}));
+jest.mock('@/lib/api', () => ({
+ tasksApi: { getAll: jest.fn() },
+ prdApi: { getAll: jest.fn() },
+ costsApi: { getTopTasks: jest.fn() },
+ integrationsApi: { getStatus: jest.fn(), getIssues: jest.fn() },
+}));
+
+// Stub heavy children; surface only what the test asserts on.
+jest.mock('@/components/tasks/TaskBoardContent', () => ({
+ TaskBoardContent: () => ,
+}));
+jest.mock('@/components/tasks/TaskDetailModal', () => ({
+ TaskDetailModal: () => null,
+}));
+jest.mock('@/components/tasks/TaskFilters', () => ({
+ TaskFilters: () => ,
+}));
+jest.mock('@/components/tasks/BatchActionsBar', () => ({
+ BatchActionsBar: () => ,
+}));
+jest.mock('@/components/tasks/BulkActionConfirmDialog', () => ({
+ BulkActionConfirmDialog: () => null,
+}));
+jest.mock('@/components/tasks/GitHubIssueImportModal', () => ({
+ GitHubIssueImportModal: ({ open }: { open: boolean }) =>
+ open ? import modal open
: null,
+}));
+
+const mockUseSWR = useSWR as jest.MockedFunction;
+
+function setupSWR(ghStatus: GitHubIntegrationStatus | undefined) {
+ mockUseSWR.mockImplementation((key) => {
+ const k = String(key);
+ if (k.includes('/integrations/github/status')) {
+ return { data: ghStatus, error: undefined, isLoading: false, mutate: jest.fn() } as never;
+ }
+ if (k.includes('/api/v2/tasks')) {
+ return {
+ data: { tasks: [], total: 0 },
+ error: undefined,
+ isLoading: false,
+ mutate: jest.fn(),
+ } as never;
+ }
+ // costs + prd
+ return { data: undefined, error: undefined, isLoading: false, mutate: jest.fn() } as never;
+ });
+}
+
+const CONNECTED: GitHubIntegrationStatus = {
+ connected: true,
+ repo: 'acme/app',
+ owner_login: 'acme',
+ owner_avatar_url: '',
+};
+const DISCONNECTED: GitHubIntegrationStatus = {
+ connected: false,
+ repo: null,
+ owner_login: null,
+ owner_avatar_url: null,
+};
+
+describe('TaskBoardView — GitHub import button', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('hides the button when GitHub is not connected', () => {
+ setupSWR(DISCONNECTED);
+ render();
+ expect(
+ screen.queryByRole('button', { name: /import from github/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the button and opens the modal when connected', () => {
+ setupSWR(CONNECTED);
+ render();
+
+ const btn = screen.getByRole('button', { name: /import from github/i });
+ expect(btn).toBeInTheDocument();
+ expect(screen.queryByTestId('import-modal')).not.toBeInTheDocument();
+
+ fireEvent.click(btn);
+ expect(screen.getByTestId('import-modal')).toBeInTheDocument();
+ });
+});
diff --git a/web-ui/src/components/tasks/GitHubIssueImportModal.tsx b/web-ui/src/components/tasks/GitHubIssueImportModal.tsx
new file mode 100644
index 00000000..9ab0b9bd
--- /dev/null
+++ b/web-ui/src/components/tasks/GitHubIssueImportModal.tsx
@@ -0,0 +1,339 @@
+'use client';
+
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import useSWR from 'swr';
+import { formatDistanceToNowStrict } from 'date-fns';
+import {
+ Search01Icon,
+ ArrowLeft01Icon,
+ ArrowRight01Icon,
+ Cancel01Icon,
+ Loading03Icon,
+ Alert02Icon,
+} from '@hugeicons/react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { Input } from '@/components/ui/input';
+import { integrationsApi } from '@/lib/api';
+import type { GitHubIssue, GitHubIssuesResponse, ApiError } from '@/types';
+
+interface GitHubIssueImportModalProps {
+ open: boolean;
+ workspacePath: string;
+ /** Connected repo slug ("owner/repo") for the header, when known. */
+ repo?: string | null;
+ onClose: () => void;
+ /** Called with the chosen issues when the user confirms the import. */
+ onImport: (selectedIssues: GitHubIssue[]) => void;
+}
+
+const PER_PAGE = 25;
+
+export function GitHubIssueImportModal({
+ open,
+ workspacePath,
+ repo,
+ onClose,
+ onImport,
+}: GitHubIssueImportModalProps) {
+ const [page, setPage] = useState(1);
+ const [searchInput, setSearchInput] = useState('');
+ const [search, setSearch] = useState('');
+ const [labelFilter, setLabelFilter] = useState('');
+ // Selected issues, keyed by number. Persists across page changes and is the
+ // source of truth for the import payload (so selections survive paging even
+ // when a row is no longer in the current page's data).
+ const [selected, setSelected] = useState
+ {githubConnected && (
+
+ )}
{/* Filters + batch actions */}
@@ -452,6 +481,15 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) {
onOpenTask={handleTaskClick}
/>
+ {/* GitHub issue import browser (issue #564) */}
+ setImportModalOpen(false)}
+ onImport={handleImportIssues}
+ />
+
{/* Bulk action confirmation */}
=> {
+ const { page = 1, perPage = 25, search = '', label = '' } = params;
+ const response = await api.get(
+ '/api/v2/integrations/github/issues',
+ {
+ params: {
+ workspace_path: workspacePath,
+ page,
+ per_page: perPage,
+ search,
+ label,
+ },
+ }
+ );
+ return response.data;
+ },
};
// Cost analytics API (issues #557, #558)
diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts
index 21d6b018..53a5fa09 100644
--- a/web-ui/src/types/index.ts
+++ b/web-ui/src/types/index.ts
@@ -676,6 +676,30 @@ export interface GitHubIntegrationStatus {
owner_avatar_url: string | null;
}
+// GitHub issue browser (issue #564)
+export interface GitHubIssue {
+ number: number;
+ title: string;
+ labels: string[];
+ assignee: string | null;
+ created_at: string;
+ html_url: string;
+}
+
+export interface GitHubIssuesResponse {
+ issues: GitHubIssue[];
+ total: number;
+ page: number;
+ per_page: number;
+}
+
+export interface GitHubIssuesParams {
+ page?: number;
+ perPage?: number;
+ search?: string;
+ label?: string;
+}
+
// Cost analytics types (issue #557)
export interface DailyCostPoint {
date: string; // ISO YYYY-MM-DD