You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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].
Copy file name to clipboardExpand all lines: CLAUDE.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -38,7 +38,7 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol
38
38
39
39
**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`.
40
40
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.
42
42
43
43
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.
0 commit comments