Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
231 changes: 231 additions & 0 deletions codeframe/core/github_issues_service.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Loading
Loading