From a313118e996522cc27ad88c7c3bea68325860a38 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 2 Jun 2026 12:00:31 -0700 Subject: [PATCH 1/3] feat(integrations): GitHub issue browser + multi-select import UI (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the issue-import browser that lets users select open issues from a connected GitHub repo (the import action itself lands in #565). Backend: - core/github_issues_service.py: headless list_issues() — open issues only, PRs filtered out, label filter, /search/issues fallback for free-text, Link-header pagination, typed error mapping (401/403/410). - GET /api/v2/integrations/github/issues on the existing #563 router, with a 60s in-process TTL cache and a 409 when no repo is connected. Frontend: - GitHubIssueImportModal: paginated list, debounced search, label filter, multi-select that persists across pages, select-all-on-page (indeterminate), selected-count badge, Import Selected gated on >=1 selection. - integrationsApi.getIssues + GitHub issue types. - TaskBoardView: 'Import from GitHub' button gated on connection status. Also fixes an unanchored .gitignore 'tasks/' rule that silently ignored web-ui/src/components/tasks/ source files. Tests: 9 service + 8 router (backend), 9 modal + 2 board (frontend). All green. --- .gitignore | 4 +- codeframe/core/github_issues_service.py | 231 ++++++++++++ .../ui/routers/github_integrations_v2.py | 126 ++++++- tests/core/test_github_issues_service.py | 225 ++++++++++++ tests/ui/test_github_integrations_v2.py | 121 +++++++ web-ui/__mocks__/@hugeicons/react.js | 2 + .../tasks/GitHubIssueImportModal.test.tsx | 181 ++++++++++ .../tasks/TaskBoardView.githubImport.test.tsx | 110 ++++++ .../tasks/GitHubIssueImportModal.tsx | 331 ++++++++++++++++++ web-ui/src/components/tasks/TaskBoardView.tsx | 42 ++- web-ui/src/lib/api.ts | 23 ++ web-ui/src/types/index.ts | 24 ++ 12 files changed, 1415 insertions(+), 5 deletions(-) create mode 100644 codeframe/core/github_issues_service.py create mode 100644 tests/core/test_github_issues_service.py create mode 100644 web-ui/src/__tests__/components/tasks/GitHubIssueImportModal.test.tsx create mode 100644 web-ui/src/__tests__/components/tasks/TaskBoardView.githubImport.test.tsx create mode 100644 web-ui/src/components/tasks/GitHubIssueImportModal.tsx 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/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/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..61a2a0ef --- /dev/null +++ b/web-ui/src/components/tasks/GitHubIssueImportModal.tsx @@ -0,0 +1,331 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import useSWR from 'swr'; +import { formatDistanceToNow } 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>(new Map()); + + // ─── Debounce the search box (300ms) before it drives a new fetch ────── + useEffect(() => { + const t = setTimeout(() => { + setSearch(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + // Reset transient state each time the modal is (re)opened. + useEffect(() => { + if (open) { + setPage(1); + setSearchInput(''); + setSearch(''); + setLabelFilter(''); + setSelected(new Map()); + } + }, [open]); + + const { data, error, isLoading } = useSWR( + open + ? ['github-issues', workspacePath, page, search, labelFilter] + : null, + () => + integrationsApi.getIssues(workspacePath, { + page, + perPage: PER_PAGE, + search, + label: labelFilter, + }) + ); + + const issues = data?.issues ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + // ─── Selection helpers ───────────────────────────────────────────────── + const toggleOne = useCallback((issue: GitHubIssue) => { + setSelected((prev) => { + const next = new Map(prev); + if (next.has(issue.number)) { + next.delete(issue.number); + } else { + next.set(issue.number, issue); + } + return next; + }); + }, []); + + const pageSelectionState = useMemo<'none' | 'some' | 'all'>(() => { + if (issues.length === 0) return 'none'; + const selectedOnPage = issues.filter((i) => selected.has(i.number)).length; + if (selectedOnPage === 0) return 'none'; + if (selectedOnPage === issues.length) return 'all'; + return 'some'; + }, [issues, selected]); + + const toggleAllOnPage = useCallback(() => { + setSelected((prev) => { + const next = new Map(prev); + const allSelected = issues.every((i) => next.has(i.number)); + if (allSelected) { + for (const i of issues) next.delete(i.number); + } else { + for (const i of issues) next.set(i.number, i); + } + return next; + }); + }, [issues]); + + const handleImport = useCallback(() => { + onImport(Array.from(selected.values())); + }, [onImport, selected]); + + return ( + !o && onClose()}> + + {/* Header */} + + + Import Issues from GitHub + {repo && ( + + · {repo} + + )} + + + + + {/* Filters */} +
+
+ + setSearchInput(e.target.value)} + placeholder="Search by title..." + aria-label="Search issues by title" + className="pl-8" + /> +
+ { + setLabelFilter(e.target.value); + setPage(1); + }} + placeholder="Label" + aria-label="Filter by label" + className="w-40" + /> +
+ + {/* Toolbar: select-all + selected count */} +
+ + {selected.size > 0 && ( + {selected.size} selected + )} +
+ + {/* Issue list */} +
+ {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + + {error && !isLoading && ( +
+ + {error.detail || 'Failed to load issues.'} +
+ )} + + {!isLoading && !error && issues.length === 0 && ( +
+ No open issues match your filters. +
+ )} + + {!isLoading && + !error && + issues.map((issue) => { + const isSelected = selected.has(issue.number); + return ( + + ); + })} +
+ + {/* Footer: pagination + actions */} +
+
+ + + Page {page} of {totalPages} + + +
+
+ + +
+
+ +
+ ); +} + +/** Render an ISO timestamp as a short relative age, tolerating bad input. */ +function formatAge(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return formatDistanceToNow(d, { addSuffix: false }) + .replace('about ', '') + .replace(' months', 'mo') + .replace(' month', 'mo') + .replace(' days', 'd') + .replace(' day', 'd') + .replace(' years', 'y') + .replace(' year', 'y') + .replace(' hours', 'h') + .replace(' hour', 'h') + .replace(' minutes', 'm') + .replace(' minute', 'm'); +} diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 6a960bd7..40600718 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -9,9 +9,10 @@ import { TaskDetailModal } from './TaskDetailModal'; import { TaskFilters } from './TaskFilters'; import { BatchActionsBar } from './BatchActionsBar'; import { BulkActionConfirmDialog, type BulkActionType } from './BulkActionConfirmDialog'; -import { Cancel01Icon, Task01Icon } from '@hugeicons/react'; +import { GitHubIssueImportModal } from './GitHubIssueImportModal'; +import { Cancel01Icon, Task01Icon, GithubIcon } from '@hugeicons/react'; import { Button } from '@/components/ui/button'; -import { tasksApi, prdApi, costsApi } from '@/lib/api'; +import { tasksApi, prdApi, costsApi, integrationsApi } from '@/lib/api'; import { useRequirementsLookup } from '@/hooks/useRequirementsLookup'; import type { TaskStatus, @@ -21,6 +22,8 @@ import type { BatchStrategy, ApiError, PrdListResponse, + GitHubIntegrationStatus, + GitHubIssue, } from '@/types'; interface TaskBoardViewProps { @@ -64,6 +67,14 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { ); const hasPrd = (prdData?.total ?? 0) > 0; + // GitHub connection status (issue #564) — gates the "Import from GitHub" + // button. Non-blocking: if this fails the board still renders without it. + const { data: ghStatus } = useSWR( + `/api/v2/integrations/github/status?path=${workspacePath}`, + () => integrationsApi.getStatus(workspacePath) + ); + const githubConnected = ghStatus?.connected === true; + // ─── Filter state ────────────────────────────────────────────── const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState(null); @@ -84,6 +95,15 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { // ─── Detail modal state ──────────────────────────────────────── const [detailTaskId, setDetailTaskId] = useState(null); + // ─── GitHub issue import modal (issue #564) ──────────────────── + const [importModalOpen, setImportModalOpen] = useState(false); + + // The actual import flow lands in #565; for now selecting + confirming + // simply closes the modal (the browser/multi-select is the #564 deliverable). + const handleImportIssues = useCallback((_selected: GitHubIssue[]) => { + setImportModalOpen(false); + }, []); + // ─── Error state for actions ─────────────────────────────────── const [actionError, setActionError] = useState(null); @@ -342,6 +362,15 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { {tasks.length} task{tasks.length !== 1 ? 's' : ''} total

+ {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 From 80012d377e0f37a0129a7244306b860d7a8464ea Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 2 Jun 2026 12:14:59 -0700 Subject: [PATCH 2/3] fix(integrations): address CodeRabbit review on #564 - Declare httpx as an explicit dependency in pyproject.toml. It was only available transitively (via anthropic/openai/etc.); this module imports it directly, so make the dependency explicit (Major). - Fix formatAge mangling: 'less than a minute' became 'less than am' and 'almost 2 years' became 'almost 2y'. Switch to formatDistanceToNowStrict (no about/almost/less-than prefixes) + clean unit abbreviation (Minor). --- pyproject.toml | 1 + .../tasks/GitHubIssueImportModal.tsx | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) 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/web-ui/src/components/tasks/GitHubIssueImportModal.tsx b/web-ui/src/components/tasks/GitHubIssueImportModal.tsx index 61a2a0ef..9ab0b9bd 100644 --- a/web-ui/src/components/tasks/GitHubIssueImportModal.tsx +++ b/web-ui/src/components/tasks/GitHubIssueImportModal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import useSWR from 'swr'; -import { formatDistanceToNow } from 'date-fns'; +import { formatDistanceToNowStrict } from 'date-fns'; import { Search01Icon, ArrowLeft01Icon, @@ -311,21 +311,29 @@ export function GitHubIssueImportModal({ ); } -/** Render an ISO timestamp as a short relative age, tolerating bad input. */ +const AGE_UNIT_ABBR: Record = { + second: 's', + minute: 'm', + hour: 'h', + day: 'd', + week: 'w', + month: 'mo', + year: 'y', +}; + +/** Render an ISO timestamp as a compact relative age (e.g. "3d", "2mo"). + * + * Uses the *strict* formatter so there are no "about "/"almost "/"less than a + * minute" prefixes to mangle; output is always " " which we then + * abbreviate. Tolerates empty/invalid input by returning "". + */ function formatAge(iso: string): string { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; - return formatDistanceToNow(d, { addSuffix: false }) - .replace('about ', '') - .replace(' months', 'mo') - .replace(' month', 'mo') - .replace(' days', 'd') - .replace(' day', 'd') - .replace(' years', 'y') - .replace(' year', 'y') - .replace(' hours', 'h') - .replace(' hour', 'h') - .replace(' minutes', 'm') - .replace(' minute', 'm'); + const strict = formatDistanceToNowStrict(d); // e.g. "3 days", "1 minute" + const match = strict.match(/^(\d+)\s+(\w+?)s?$/); + if (!match) return strict; + const [, count, unit] = match; + return `${count}${AGE_UNIT_ABBR[unit] ?? unit}`; } From 016ebfcf5f1d2c7bd14bf867bd64049b57b6f525 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 2 Jun 2026 12:19:48 -0700 Subject: [PATCH 3/3] docs: mark Phase 5.5 issue browse (#564) complete in CLAUDE.md --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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.