fix(github): unambiguous issue cache key, no search-qualifier injection, rate-limit vs scope (#956) - #1089
Conversation
…on, 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
Demo — acceptance-criteria evidenceRan the real code paths (only the HTTP transport is mocked): |
Third-party review (post-PR)
No findings. (opencode/GLM was skipped as primary — on this repo it has a history of mutating the working tree and timing out with zero output; |
|
Claude finished @frankbria's task in 4m 48s —— View job GLM bug-hunting review — complete ✅
✅ GLM review: no defects found. Scope was concrete defects only (logic errors, null/error paths, security, data loss, races, contract breaks). What I checked and why each holds:
One theoretical non-defect I considered and discarded (below the precision bar): a trailing backslash in the search term (e.g. |
Code ReviewReviewed the diff directly (couldn't run the local test suite in this sandbox — verified statically by reading the full modified files and tracing call sites instead of relying on the PR's own reported test run). Overall: solid fix, well-scoped to the three defects described, and the evidence in the PR body/demo comment matches what the code actually does. Correctness — traced through each defect
Consistency
Minor observations (non-blocking)
Test coverageNew tests ( No blocking issues found. |
Code ReviewTraced the diff against Agrees with the prior review on this PR ("No blocking issues found") — independently re-derived the same conclusions:
No new correctness issues found beyond what's already noted in the thread. One trivial, non-blocking nit not previously mentioned: Test coverage for the three defects (cache collision, qualifier injection incl. embedded-quote and multiword AND-of-terms cases, rate-limit-vs-scope incl. both primary/secondary detection paths) looks complete. |
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].
|
Claude finished @frankbria's task in 4m 30s —— View job GLM bug-hunting review — complete ✅
✅ GLM review: no defects found. Re-ran independently on the final (
Considered and discarded (below the precision bar): a search term ending in |
ReviewSolid, well-tested fix for the three defects described in #956 — the tuple cache key, the component-wise invalidation match, and the 403-vs-429 rate-limit classification all look correct and are backed by good regression tests (including the adversarial One real gap: the search-qualifier-injection fix doesn't cover
|

Closes #956.
Three defects in the GitHub Issues integration, each with a test that fails on
main.1. Cache-key ambiguity — wrong data, no error
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:The key is now a native tuple — unambiguous by construction, no encoding needed.
_issue_cache_invalidatematches components (k[0] == repo and k[-1] == user_id) rather than string prefix/suffix, so an entry for a different repo whose search text happens to spell the connected repo name is no longer swept.2. Search text could introduce qualifiers
The user's search string was joined verbatim alongside the
repo:/is:qualifiers, so a search ofrepo:other/thingreached 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 (GitHub treats a quoted string as literal). Per word rather than one big phrase so
login bugstays AND-of-terms instead of silently becoming an exact-phrase search:Embedded quotes are removed rather than escaped — escaping semantics inside GitHub's query language are version-dependent; dropping them is the one behaviour that can't be talked into opening a second phrase. A term with nothing searchable left (
"") returns""and falls back to the plain list endpoint rather than sending an empty phrase.3. A throttled 403 was reported as a missing scope
Every 403 raised
InsufficientScopeError("... missing issues:read scope"), sending users off to regenerate a PAT that was never the problem.403/429 are now classified by
Retry-After/X-RateLimit-Remaining: 0/ a body message naming the limit:Retry-After: 60)X-RateLimit-Remaining: 0)New
RateLimitedError(GitHubConnectError)+ErrorCodes.RATE_LIMITED. It subclasses the existing base, so every broadexcept GitHubConnectErrorhandler (reconciliation, auto-close) keeps working.X-RateLimit-Resetis deliberately not echoed as a retry hint — it's an absolute unix timestamp and would read as a nonsense wait.Incidental cleanup
get_issueshad its own inlineInvalidTokenError/InsufficientScopeError/GitHubConnectErrorchain duplicating_map_github_error. Since all typed errors subclassGitHubConnectError, that collapses to oneexceptdelegating to the shared mapper — ~15 lines deleted, and the new 429 case only had to be written once.Verification
main.uv run pytest tests/core/test_github_issues_service.py tests/ui/test_github_integrations_v2.py tests/core/test_github_connect_service.py tests/core/test_github_pagination_940.py tests/core/test_github_issue_reconciliation_1032.py tests/core/test_task_github_traceability.py→ 159 passeduv run ruff check codeframe/ tests/→ cleancodex review --base main→ no findings.Known limitations
GitHubIssueImportModalrenderserror.detail, andnormalizeErrorDetailsurfaces the new rate-limit message text as-is. The axios interceptor only special-cases 401, so a 429 displays rather than redirecting.