Skip to content

Commit d50ae2b

Browse files
authored
fix(github): unambiguous issue cache key, no search-qualifier injection, rate-limit vs scope (#956) (#1089)
* fix(github): unambiguous issue cache key, no search-qualifier injection, rate-limit vs scope (#956) Three defects in the GitHub Issues integration: 1. Cache-key ambiguity — the browse cache keyed on f"{repo}|{page}|{per_page}|{search}|{label}|{user_id}", so a '|' typed into the search box shifted into the label field and served one filter's results for another (wrong data, no error). The key is now a native tuple, unambiguous by construction; invalidation matches components instead of string prefix/suffix, so a search term spelling the repo name can't be swept. 2. Search-qualifier injection — the user's search string was joined verbatim alongside repo:/is: qualifiers, so a search of `repo:other/thing` reached repositories outside the connected one; in a hosted deployment that makes the operator's PAT enumerable through a text field. Free text is now quoted per word (literal to GitHub, AND-of-terms semantics preserved); a term with nothing searchable left falls back to the plain list endpoint. 3. Misreported 403 — every 403 was reported as "missing issues:read scope", sending users to regenerate a PAT that was never the problem. 403/429 are now classified by Retry-After / X-RateLimit-Remaining / body message into a new RateLimitedError (-> HTTP 429, ErrorCodes.RATE_LIMITED, distinct text) vs. a genuine InsufficientScopeError (-> 403). Also collapses get_issues' inline error chain into the existing shared _map_github_error, deleting ~15 lines of duplicated mapping. Closes #956 * docs: record the #956 GitHub-issues invariants in CLAUDE.md * test: update tenant-isolation cache assertions to the tuple key (#956) tests/ui/test_credential_tenant_isolation.py asserted on the old '|'-joined string key ('acme/app|1|25|||1', k.endswith("|1")). Same invariants, expressed against the tuple: user_id is k[-1].
1 parent 3f1b3b9 commit d50ae2b

7 files changed

Lines changed: 443 additions & 69 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol
3838

3939
**Phase 5.5 is complete** — 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 → 400/404/403 — a bad PAT is 400 `UPSTREAM_AUTH_FAILED`, never 401, so the web UI doesn't treat it as session expiry, #734). 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`.
4040

41-
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 → 502/403/502 (a rejected stored PAT is 502 `UPSTREAM_AUTH_FAILED`, never 401 — #734; `pr_v2.py` likewise remaps upstream GitHub 401s to 502 via `_github_error_http`). 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.
41+
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 → 502/403/429/502 (a rejected stored PAT is 502 `UPSTREAM_AUTH_FAILED`, never 401 — #734; `pr_v2.py` likewise remaps upstream GitHub 401s to 502 via `_github_error_http`). Three #956 invariants hold here: the cache key is a **tuple** `(repo, page, per_page, search, label, user_id)` — never a `|`-joined string, which let a `|` in the search text collide with the label field; free-text search is quoted **per word** by `_sanitize_search` so it cannot inject `repo:`/`is:` qualifiers and escape the connected repo; and a 403/429 that is really throttling (`Retry-After` / `X-RateLimit-Remaining: 0` / a body message naming the limit) raises `RateLimitedError` → **429** `RATE_LIMITED` instead of being misreported as a missing `issues:read` scope. 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.
4242

4343
Issue **import + traceability** (#565) is **complete**: `POST /api/v2/integrations/github/import` (same router) turns selected issues into tasks — title verbatim, body as description (+ a best-effort `**Labels:**` footer), linked via `github_issue_number` + `external_url`; PRs are rejected (`NotAnIssueError`→422), missing issues 404, fetch failures 502, malformed saved repo 409. Import is two-phase (fetch+dedupe all, then create) with rollback on a mid-create DB error; dedup is keyed on the full issue URL and backed by a `UNIQUE(workspace_id, external_url)` index (atomic across concurrent imports). Issue ops live in `core/github_issues_service.py` (`get_issue`, `close_issue`). **Auto-close**: marking an opted-in imported task DONE closes the linked issue — fired from core `tasks.update_status` so the web UI, CLI, and agent/batch paths all trigger it; the close targets the task's *source* repo parsed from `external_url` (not the live connection) and runs off the caller's path (event loop in the server, non-daemon thread in CLI). `TaskResponse` exposes the three traceability fields; `PATCH /api/v2/tasks/{id}` accepts `auto_close_github_issue` (persist-first + rollback-on-rejected-transition, with late opt-in on already-DONE tasks). Frontend: `GitHubIssueBadge`, import wiring in `TaskBoardView` (progress, in-modal error, summary banner), badge + auto-close checkbox in `TaskDetailModal`, `integrationsApi.importIssues` + `tasksApi.updateGitHubSettings`. **Known limitation**: auto-close uses the single machine-wide GIT_GITHUB PAT, so closing an older imported repo's issue after reconnecting to a different repo may fail if that PAT lacks access.
4444

codeframe/core/github_issues_service.py

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ class IssueNotFoundError(Exception):
5555
callers map it to a 4xx rather than a 502.
5656
"""
5757

58+
59+
class RateLimitedError(GitHubConnectError):
60+
"""GitHub throttled us — primary or secondary rate limit (#956).
61+
62+
GitHub answers a rate-limited request with 403 (sometimes 429), the same
63+
status it uses for a token that lacks a scope. Reporting both as "missing
64+
issues:read scope" sent users off to regenerate a PAT that was never the
65+
problem, so the two are distinguished by headers/body here.
66+
"""
67+
5868
# Parse the ``page=N`` query param out of a Link header's rel="last" URL.
5969
_LAST_PAGE_RE = re.compile(r'[?&]page=(\d+)[^>]*>;\s*rel="last"')
6070

@@ -87,14 +97,53 @@ def _simplify(raw: dict) -> GitHubIssue:
8797
}
8898

8999

90-
def _raise_for_status(status_code: int, *, context: str) -> None:
91-
"""Map a GitHub HTTP status to a typed error. 2xx/410 are handled by callers."""
100+
def _rate_limit_retry_after(resp: httpx.Response) -> Optional[str]:
101+
"""Return the retry hint if ``resp`` is a rate-limit rejection, else ``None``.
102+
103+
GitHub signals throttling three ways, any one of which is sufficient: a
104+
``Retry-After`` header (secondary limit), an exhausted
105+
``X-RateLimit-Remaining``, or a body message naming the limit. The empty
106+
string means "throttled, no hint" — distinct from ``None`` (not throttled).
107+
Only ``Retry-After`` carries a delay; ``X-RateLimit-Reset`` is an absolute
108+
unix timestamp and would read as a nonsense wait if echoed as seconds.
109+
"""
110+
retry_after = resp.headers.get("retry-after")
111+
if retry_after:
112+
return retry_after
113+
if resp.headers.get("x-ratelimit-remaining") == "0":
114+
return ""
115+
try:
116+
body = resp.json()
117+
message = str(body.get("message", "")) if isinstance(body, dict) else ""
118+
except Exception:
119+
message = ""
120+
if "rate limit" in message.lower():
121+
return ""
122+
return None
123+
124+
125+
def _raise_403(resp: httpx.Response, scope_message: str) -> None:
126+
"""Raise the right error for a 403/429: throttling vs. a genuine scope gap."""
127+
retry_after = _rate_limit_retry_after(resp)
128+
if retry_after is None and resp.status_code != 429:
129+
raise InsufficientScopeError(scope_message)
130+
hint = f" Retry after {retry_after}s." if retry_after else ""
131+
raise RateLimitedError(
132+
"GitHub rate limit exceeded; the request was throttled, "
133+
f"not rejected for missing permissions.{hint}"
134+
)
135+
136+
137+
def _raise_for_status(resp: httpx.Response, *, context: str) -> None:
138+
"""Map a GitHub response to a typed error. 2xx/410 are handled by callers."""
139+
status_code = resp.status_code
92140
if status_code == 401:
93141
raise InvalidTokenError("Invalid GitHub token.")
94-
if status_code == 403:
95-
raise InsufficientScopeError(
142+
if status_code in (403, 429):
143+
_raise_403(
144+
resp,
96145
"Token cannot read issues for this repository "
97-
"(missing issues:read scope)."
146+
"(missing issues:read scope).",
98147
)
99148
if status_code >= 400:
100149
raise GitHubConnectError(
@@ -154,7 +203,8 @@ async def list_issues(
154203
Raises:
155204
ValueError: if ``repo`` is not a valid ``owner/repo`` string.
156205
InvalidTokenError: GitHub returned 401.
157-
InsufficientScopeError: the token cannot read issues (403).
206+
InsufficientScopeError: the token cannot read issues (403, not throttled).
207+
RateLimitedError: GitHub throttled the request (403/429).
158208
GitHubConnectError: any other non-success response or network error.
159209
"""
160210
owner, name = parse_repo(repo)
@@ -164,9 +214,10 @@ async def list_issues(
164214
client = httpx.AsyncClient(timeout=_TIMEOUT)
165215
try:
166216
headers = _headers(pat)
167-
if search.strip():
217+
term = _sanitize_search(search)
218+
if term:
168219
return await _search_issues(
169-
client, headers, owner, name, page, per_page, search, label
220+
client, headers, owner, name, page, per_page, term, label
170221
)
171222
return await _list_issues(
172223
client, headers, owner, name, page, per_page, label
@@ -205,7 +256,7 @@ async def _list_issues(
205256
# 410 Gone == issues disabled on the repo: nothing to import, not an error.
206257
if resp.status_code == 410:
207258
return [], 0
208-
_raise_for_status(resp.status_code, context="issues list")
259+
_raise_for_status(resp, context="issues list")
209260

210261
raw_items = resp.json()
211262
if not isinstance(raw_items, list):
@@ -255,7 +306,8 @@ async def get_issue(
255306
Raises:
256307
ValueError: if ``repo`` is not a valid ``owner/repo`` string.
257308
InvalidTokenError: GitHub returned 401.
258-
InsufficientScopeError: the token cannot read issues (403).
309+
InsufficientScopeError: the token cannot read issues (403, not throttled).
310+
RateLimitedError: GitHub throttled the request (403/429).
259311
GitHubConnectError: any other non-success response or network error.
260312
"""
261313
owner, name = parse_repo(repo)
@@ -288,10 +340,8 @@ async def get_issue(
288340
raise GitHubConnectError("Could not reach GitHub. Try again later.")
289341
if repo_resp.status_code == 401:
290342
raise InvalidTokenError("Invalid GitHub token.")
291-
if repo_resp.status_code == 403:
292-
raise InsufficientScopeError(
293-
"Token lacks access to this repository."
294-
)
343+
if repo_resp.status_code in (403, 429):
344+
_raise_403(repo_resp, "Token lacks access to this repository.")
295345
if repo_resp.status_code == 404:
296346
raise GitHubConnectError(
297347
f"Repository '{repo}' is no longer accessible."
@@ -311,7 +361,7 @@ async def get_issue(
311361
f"GitHub repo check returned status {repo_resp.status_code}."
312362
)
313363
raise IssueNotFoundError(f"Issue #{number} was not found in '{repo}'.")
314-
_raise_for_status(resp.status_code, context="get issue")
364+
_raise_for_status(resp, context="get issue")
315365

316366
raw = resp.json()
317367
if not isinstance(raw, dict):
@@ -370,7 +420,8 @@ async def close_issue(
370420
Raises:
371421
ValueError: if ``repo`` is not a valid ``owner/repo`` string.
372422
InvalidTokenError: GitHub returned 401.
373-
InsufficientScopeError: the token cannot write issues (403).
423+
InsufficientScopeError: the token cannot write issues (403, not throttled).
424+
RateLimitedError: GitHub throttled the request (403/429).
374425
GitHubConnectError: any other non-success response or network error.
375426
"""
376427
owner, name = parse_repo(repo)
@@ -409,7 +460,7 @@ async def close_issue(
409460
logger.warning("GitHub close issue failed: %s", type(exc).__name__)
410461
raise GitHubConnectError("Could not reach GitHub. Try again later.")
411462

412-
_raise_for_status(resp.status_code, context="close issue")
463+
_raise_for_status(resp, context="close issue")
413464
# A redirect (3xx) — e.g. a moved/renamed/transferred repo — means the
414465
# PATCH was NOT applied (httpx does not follow redirects by default), so
415466
# the issue is still open. Treat it as a failure rather than reporting a
@@ -425,18 +476,43 @@ async def close_issue(
425476
await client.aclose()
426477

427478

479+
def _sanitize_search(search: str) -> str:
480+
"""Quote each free-text word so none can act as a qualifier (#956).
481+
482+
The term is joined into a ``q`` alongside ``repo:``/``is:`` qualifiers, so
483+
raw text lets a user smuggle in their own: a search of ``repo:other/thing``
484+
used to reach repositories outside the connected one, making the operator's
485+
PAT enumerable through a text field in a hosted deployment. GitHub treats a
486+
double-quoted string as literal, so quoting neutralises every qualifier.
487+
488+
Quoting *per word* rather than the whole string keeps the existing AND-of-
489+
terms behaviour — one big phrase would silently turn "login bug" into an
490+
exact-phrase search and drop results users used to get.
491+
492+
Embedded quotes are *removed*, not escaped — escaping semantics inside
493+
GitHub's query language are version-dependent, and dropping them is the one
494+
behaviour that cannot be talked into opening a second phrase.
495+
496+
Returns ``""`` when nothing searchable survives, so the caller falls back to
497+
the plain list endpoint rather than sending an empty phrase.
498+
"""
499+
words = search.replace('"', " ").split()
500+
return " ".join(f'"{w}"' for w in words)
501+
502+
428503
async def _search_issues(
429504
client: httpx.AsyncClient,
430505
headers: dict[str, str],
431506
owner: str,
432507
name: str,
433508
page: int,
434509
per_page: int,
435-
search: str,
510+
term: str,
436511
label: str,
437512
) -> tuple[list[GitHubIssue], int]:
513+
"""Search issues. ``term`` must already be ``_sanitize_search``-ed."""
438514
qualifiers = [
439-
search.strip(),
515+
term,
440516
f"repo:{owner}/{name}",
441517
"is:issue",
442518
"is:open",
@@ -454,7 +530,7 @@ async def _search_issues(
454530
logger.warning("GitHub issues search failed: %s", type(exc).__name__)
455531
raise GitHubConnectError("Could not reach GitHub. Try again later.")
456532

457-
_raise_for_status(resp.status_code, context="issues search")
533+
_raise_for_status(resp, context="issues search")
458534

459535
data = resp.json()
460536
if not isinstance(data, dict):

codeframe/ui/response_models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ class ErrorCodes:
166166
# Distinct from UNAUTHORIZED so it is never carried on a 401 — the web UI
167167
# treats any 401 as CodeFRAME session expiry and logs the user out (#734).
168168
UPSTREAM_AUTH_FAILED = "UPSTREAM_AUTH_FAILED"
169+
# An upstream service throttled us (GitHub rate limit). Distinct from
170+
# FORBIDDEN so a transient 429 is never read as "your token lacks a
171+
# scope" — that sent users to regenerate a working PAT (#956).
172+
RATE_LIMITED = "RATE_LIMITED"
169173

170174
# Server errors (5xx)
171175
INTERNAL_ERROR = "INTERNAL_ERROR"

codeframe/ui/routers/github_integrations_v2.py

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
_TIMEOUT,
4141
IssueNotFoundError,
4242
NotAnIssueError,
43+
RateLimitedError,
4344
get_issue,
4445
list_issues,
4546
)
@@ -160,9 +161,15 @@ class ImportResponse(BaseModel):
160161
# within the same server process. Bounded to
161162
# _ISSUE_CACHE_MAX_SIZE entries and swept on every set so search text (an
162163
# unbounded key space) can't accumulate stale payloads forever (#761).
164+
#
165+
# The key is a TUPLE, not a delimiter-joined string: joining on '|' meant a '|'
166+
# typed into the search box shifted into the label field and served one filter's
167+
# results for another — wrong data, no error (#956). A tuple is unambiguous by
168+
# construction and needs no encoding.
163169
_ISSUE_CACHE_TTL_SECONDS = 60.0
164170
_ISSUE_CACHE_MAX_SIZE = 256
165-
_ISSUE_CACHE: dict[str, tuple[float, Any]] = {}
171+
_IssueCacheKey = tuple # (repo, page, per_page, search, label, user_id)
172+
_ISSUE_CACHE: dict[_IssueCacheKey, tuple[float, Any]] = {}
166173

167174

168175
def _evict_issue_cache() -> None:
@@ -178,7 +185,7 @@ def _evict_issue_cache() -> None:
178185
del _ISSUE_CACHE[key]
179186

180187

181-
def _issue_cache_get(key: str) -> Optional[Any]:
188+
def _issue_cache_get(key: _IssueCacheKey) -> Optional[Any]:
182189
entry = _ISSUE_CACHE.get(key)
183190
if entry is None:
184191
return None
@@ -189,7 +196,7 @@ def _issue_cache_get(key: str) -> Optional[Any]:
189196
return payload
190197

191198

192-
def _issue_cache_set(key: str, payload: Any) -> None:
199+
def _issue_cache_set(key: _IssueCacheKey, payload: Any) -> None:
193200
_ISSUE_CACHE[key] = (time.monotonic() + _ISSUE_CACHE_TTL_SECONDS, payload)
194201
_evict_issue_cache()
195202

@@ -199,13 +206,12 @@ def _issue_cache_invalidate(repo: str, user_id: Optional[int]) -> None:
199206
200207
Called after an import so reopening the browse modal doesn't keep offering
201208
just-imported issues as selectable (they would now be skipped as dupes).
202-
Keys are ``repo|page|per_page|search|label|user_id``; only drop entries
203-
belonging to the calling user so one tenant's import does not wipe another's
204-
cache for the same repo (#790).
209+
Keys are ``(repo, page, per_page, search, label, user_id)``; only drop
210+
entries belonging to the calling user so one tenant's import does not wipe
211+
another's cache for the same repo (#790). Matching is component-wise, so a
212+
search term that happens to spell the repo name can't be swept (#956).
205213
"""
206-
prefix = f"{repo}|"
207-
suffix = f"|{user_id}"
208-
for key in [k for k in _ISSUE_CACHE if k.startswith(prefix) and k.endswith(suffix)]:
214+
for key in [k for k in _ISSUE_CACHE if k[0] == repo and k[-1] == user_id]:
209215
_ISSUE_CACHE.pop(key, None)
210216

211217

@@ -410,9 +416,9 @@ async def get_issues(
410416
per_page = max(1, min(per_page, 100))
411417
repo = cfg["repo"]
412418

413-
# The user component keeps the repo|-prefixed invalidation in
414-
# _issue_cache_invalidate working while separating tenants (#790).
415-
cache_key = f"{repo}|{page}|{per_page}|{search}|{label}|{auth.get('user_id')}"
419+
# Tuple key: repo first and user last so _issue_cache_invalidate can match
420+
# both component-wise while keeping tenants separated (#790, #956).
421+
cache_key = (repo, page, per_page, search, label, auth.get("user_id"))
416422
cached = _issue_cache_get(cache_key)
417423
if cached is not None:
418424
return cached
@@ -432,23 +438,10 @@ async def get_issues(
432438
status_code=409,
433439
detail=api_error(str(e), ErrorCodes.CONFLICT),
434440
)
435-
except InvalidTokenError as e:
436-
# 502, never 401: the stored PAT was rejected upstream — the caller's
437-
# CodeFRAME session is fine (#734).
438-
raise HTTPException(
439-
status_code=502,
440-
detail=api_error(str(e), ErrorCodes.UPSTREAM_AUTH_FAILED),
441-
)
442-
except InsufficientScopeError as e:
443-
raise HTTPException(
444-
status_code=403,
445-
detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR),
446-
)
447441
except GitHubConnectError as e:
448-
raise HTTPException(
449-
status_code=502,
450-
detail=api_error(str(e), ErrorCodes.EXECUTION_FAILED),
451-
)
442+
# Every typed GitHub error subclasses this; _map_github_error is the
443+
# single mapping shared with the import path.
444+
raise _map_github_error(e)
452445

453446
response = GitHubIssuesResponse(
454447
issues=[GitHubIssueItem(**issue) for issue in issues],
@@ -493,6 +486,12 @@ def _map_github_error(e: Exception) -> HTTPException:
493486
return HTTPException(
494487
status_code=403, detail=api_error(str(e), ErrorCodes.VALIDATION_ERROR)
495488
)
489+
if isinstance(e, RateLimitedError):
490+
# 429, not 403: throttling is transient and retryable — reporting it as
491+
# a scope gap sends users to regenerate a PAT that was fine (#956).
492+
return HTTPException(
493+
status_code=429, detail=api_error(str(e), ErrorCodes.RATE_LIMITED)
494+
)
496495
return HTTPException(
497496
status_code=502, detail=api_error(str(e), ErrorCodes.EXECUTION_FAILED)
498497
)

0 commit comments

Comments
 (0)