Skip to content

fix: code-review fixes — rate limits, URL hardening, robustness, a11y, coverage - #82

Merged
jaylann merged 6 commits into
stagefrom
fix/review-tier1-rate-limits
Jul 4, 2026
Merged

fix: code-review fixes — rate limits, URL hardening, robustness, a11y, coverage#82
jaylann merged 6 commits into
stagefrom
fix/review-tier1-rate-limits

Conversation

@jaylann

@jaylann jaylann commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the fixes from a full-codebase review (5 parallel review agents + direct verification). Organized into the tiers from the review plan; each tier is independently reviewable in the two commits.

245 tests pass (+23 new), just check clean, build + launch smoke test green.

Tier 1 — behavioral bugs (eaad5f5)

  • Rate limiting: 403/429 + rate-limit headers → ClientError.rateLimited(until:); the poll loop backs off to the reset time (skipping the request-heavy CI/repo-feed hydration while limited) instead of hammering into GitHub's secondary limit. Message surfaced to the UI.
  • Legacy-token dead-end: a 401 on legacy-token migration now drops the dead token so the UI falls back to sign-in instead of a permanent first-load skeleton.
  • Device-flow slow_down: replace the poll interval instead of accumulating it.
  • Notification 401s: markRead/markAllRead route to the reconnect prompt, matching the quick-action path.
  • reviews() pagination: walk the Link header to the last page so a >100-review PR's latest verdict gates Approve/Merge.

Tier 2 — URL/scheme hardening (fbe146b)

  • Reject non-https base URLs in GitHubClient.makeRequest (no bearer token over cleartext); validate the Enterprise host field at submit with an inline hint.
  • New WebLink scheme allowlist gates every URL-open site (NSWorkspace in NotificationService, openURL in menu/notification rows) to http(s) only — a hostile self-hosted host can't return a file:/javascript: deep link.

Tier 3 — robustness

  • Close the concurrent force-refresh race (coalesce onto a peer's fresh run).
  • Tolerant ISO8601 decoding (fractional seconds) so one field can't fail a whole page decode.
  • Nil mergeReadinessTask on completion/sign-out; reject ./.. traversal in normalizedSlug; reset stale search state when the filter bar unmounts; persist the OAuth client ID only on success and clear the pasted PAT on mode-switch/close.
  • justfile: tee the full build log + surface error lines instead of tail -30.

Tier 4 — accessibility

  • Expose selected/toggle state to VoiceOver on the tab bar, segmented control, and filter chips; hide the decorative UnseenDot when seen.

Tier 5 — coverage + CI

  • New tests: reconnect (in-place token swap / reentrancy / failure), addAccount invalid + duplicate, maintainer-without-push merge, CI-passed banner, https rejection, WebLink, normalizedSlug traversal.
  • Cache resolved SwiftPM/Tuist deps in the build & test CI jobs.

Deferred (intentionally, want your call)

  • Full Dynamic Type support — anchoring every font token to a text style would materially change the dense, fixed-width popover layout; it's a design decision for you rather than a silent refactor.
  • Protocol-level GitHubAPIError — the store still matches GitHubClient.ClientError. YAGNI until a second GitHubAPI backend exists; low value, non-trivial churn.
  • Decoding GitHub error message bodies into ClientError.http — would change its Equatable shape and ripple through many test matches for marginal benefit.

🤖 Generated with Claude Code

jaylann added 3 commits July 4, 2026 00:36
…review verdicts

Tier 1 of the code-review fixes:

- Map 403/429 with rate-limit headers to ClientError.rateLimited(until:) and
  back the poll loop off to the reset time instead of hammering into GitHub's
  secondary limit; skip the request-heavy CI/repo-feed hydration while limited.
- Drop a legacy single-token credential that returns 401 on migration so the
  UI falls back to sign-in instead of a permanent first-load skeleton.
- Replace (don't accumulate) the device-flow poll interval on slow_down.
- Route notification mark-read / mark-all 401s to the reconnect prompt, matching
  the quick-action path.
- Paginate reviews() to the last page so a >100-review PR's latest verdict is
  used to gate Approve/Merge.

Extract the poll loop into AppStorePolling.swift and fold per-account load
failures into a LoadFailures struct to stay within lint budgets.
Tier 2 — URL/scheme hardening:
- Reject non-https base URLs in GitHubClient.makeRequest so the bearer token
  can't go out over cleartext; validate the Enterprise host field at submit.
- Add WebLink scheme allowlist and gate every URL-open site (NSWorkspace in
  NotificationService, openURL in the menu rows/notification rows) on http(s).

Tier 3 — robustness:
- Close the concurrent force-refresh race (coalesce onto a peer's fresh run).
- Tolerant ISO8601 decoding (fractional seconds) so one field can't fail a page.
- Nil mergeReadinessTask on completion/sign-out.
- Reject '.'/'..' path-traversal segments in normalizedSlug.
- Reset stale search state when the filter bar unmounts.
- Persist the OAuth client ID only on a successful sign-in; clear the pasted PAT
  on mode-switch and window close.
- justfile: tee the full build log and surface error lines instead of tail -30.

Tier 4 — accessibility:
- Expose selected/toggle state to VoiceOver on the tab bar, segmented control,
  and filter chips; hide the decorative UnseenDot when seen.

Tier 5 — coverage + CI:
- Tests for reconnect (in-place token swap / reentrancy / failure), addAccount
  invalid + duplicate, maintainer-without-push merge, CI-passed banner, https
  rejection, WebLink, and normalizedSlug traversal (245 tests, +12).
- Cache resolved SwiftPM/Tuist deps in the build & test CI jobs.

Extract test hooks (AppStore+TestHooks) and WebLink (Support/) to sibling files
to stay within lint budgets. Full Dynamic Type support and a protocol-level API
error type are deferred (design decision / YAGNI) — see PR notes.
From three adversarial review agents over the diff:

- Data race (real): the ISO8601 fractional-seconds decoder shared a mutable
  ISO8601DateFormatter across the multi-account TaskGroup — swap to the Sendable
  value-type Date.ISO8601FormatStyle (no nonisolated(unsafe)).
- Gate the device-flow verificationUri (host-returned) through WebLink at both
  add-account and 401-reconnect sites — the one host URL the allowlist missed.
- Keep the first-load skeleton (don't flash an empty state) when a legacy-token
  migration fails transiently vs. is rejected (401).

Coverage the review flagged as missing:
- Extract a pure AppStore.pollDelay(...) and test the rate-limit backoff honours
  the reset and the cadence floor (the headline fix was previously only asserted
  at the field level).
- Make HostField internal and add HostFieldTests (was private → untestable).
- Assert hydration is actually skipped while rate-limited (pullRequestCount == 0).
- Test ISO8601 fractional/plain/invalid decoding, reviews() page-cap termination,
  and the rate-limit / 429 AuthErrorCopy branches.
- Drop a misleading normalizedSlug case rejected by the arity guard, not the new
  traversal check.

262 → 256 tests, lint clean, build + smoke green.
@jaylann

jaylann commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

Self-review round (3 adversarial agents over the diff)

Ran three review agents against the branch diff; acted on every real finding (d45eb60):

Confirmed & fixed

  • 🐞 Data race — the new ISO8601 fractional-seconds decoder shared a mutable ISO8601DateFormatter across the multi-account TaskGroup (that class is not documented thread-safe, unlike DateFormatter). Swapped to the Sendable value-type Date.ISO8601FormatStyle.
  • 🔒 Missed open site — the device-flow verificationUri (host-returned) bypassed the WebLink allowlist at both add-account and 401-reconnect. Now gated.
  • Skeleton nit — a transient legacy-migration failure now keeps the loading skeleton instead of flashing an empty state (only a rejected token drops to sign-in).

Coverage the review flagged as missing (added)

  • Extracted a pure AppStore.pollDelay(...) and tested the backoff honours the reset and the cadence floor (was only asserted at field level).
  • Made HostField testable + HostFieldTests (was private).
  • Assert hydration is actually skipped while rate-limited (pullRequestCount == 0).
  • ISO8601 fractional/plain/invalid decoding, reviews() page-cap termination, 429/rate-limit copy.

Verified-correct by the reviewers (no change needed): the force-refresh race fix, rate-limit backoff clamping, reviews() pagination, https guard, slug traversal guard, VoiceOver traits, the reconnect same-slot-swap test, and the pinned actions/cache SHA.

Noted: the SPM half of the CI cache is a near-no-op today (no external SPM deps); the Tuist-cache half still helps. Left in place to future-proof.

256 tests, just check clean, build + launch smoke green.

jaylann added 3 commits July 4, 2026 01:12
A refresh hydrates every distinct open PR (detail + check-runs + reviews)
every poll — an N+1 that exhausted the core 5000/hr limit on a large inbox
(~189 core requests/refresh at the 60s cadence). Two mitigations:

- ETag revalidation: requests now use `.reloadRevalidatingCacheData` over a
  private URLCache, so an unchanged resource returns a rate-limit-free 304.
  This also supersedes the old cache-bypass hack — a changed resource still
  returns a fresh 200, so approve/merge buttons stay current.
- updated_at short-circuit: a PR unchanged since its last hydration skips the
  detail+reviews refetch and reuses the cached gate, re-reading only its
  check-runs against the cached head sha (so a CI re-run flip is still caught).

Verified live: three consecutive polls consumed ~61 core total (budget flat),
with request count falling 193 -> 81 -> 38 as the hydration marks accumulate.

Also: harden JSON date decoding coverage (fractional seconds through every
required field), pin reviews single-page cost, and log remaining rate budget
on 2xx. GraphQL batching to collapse the N+1 structurally is tracked in #83.
Address code-review findings on the hydration N+1 cut:

- Never take the checks-only skip for a PR whose Merge button is showing. A
  base-branch advance can flip its mergeable_state to behind/dirty without
  bumping the PR's updated_at, so skipping would keep serving a stale Merge
  button that 405s on click. Mergeable PRs now always refetch (a cheap 304
  while genuinely unchanged, a fresh 200 the moment they go behind).
- Switch the revalidation URLCache to memory-only (diskCapacity 0) so private
  PR/review bodies aren't persisted to disk at rest. Steady-state polling stays
  cheap via in-session 304s; a cold launch pays one full poll.

Tests: pin that a mergeable PR is not skipped, and a deterministic checks-only
CI-flip-notifies test (fixed updated_at) covering the skip path.
@jaylann
jaylann merged commit 118bc29 into stage Jul 4, 2026
7 checks passed
@jaylann
jaylann deleted the fix/review-tier1-rate-limits branch July 4, 2026 18:02
jaylann added a commit that referenced this pull request Jul 5, 2026
… republish (#84) (#85)

## What

Fixes #84 — the post-approval **merge-readiness poll**'s gate write
being clobbered by a concurrent **CI hydration wave**'s batch republish,
which briefly hides (or wrongly shows) the Merge button after an
approval.

## Root cause

`prGates` (the per-PR Approve/Merge action gate) is written by two
`@MainActor` flows that interleave across `await`:
- the hydration wave (`drainChecks`/`fold`/`publishChecks`), which
refetches many PRs and republishes gates in batches;
- `refreshPRState`, a single-PR gate refetch driven by the post-approve
poll.

A wave that read a PR's gate *before* the poll's single-key write would
republish the whole map and clobber it. The `checksGeneration` guard
only catches cross-generation clobbers; this is a same-generation
interleave. #82's `.checksOnly` path widened the window.

## Approach — issue-time write-recency

The naive fixes fail: "poll always wins" shows a Merge button that 405s
when the base advanced; a **commit-time** clock still loses because the
wave's full fetch reads its gate *early* (from the PR detail) but folds
*late* (after its slow checks/reviews legs), so a wave that observed
older state commits last and wrongly wins.

Fix: a monotonic **issue-time** clock (`gateWriteClock` + `prGateSeq`).
Both writers take a tick when they *issue* their fetch — the wave stamps
a per-full-fetch issue seq up front; the poll ticks before its detail
request and defers to any concurrently-issued newer write.
`publishChecks` merges gates onto the live map per key, applying a wave
gate only when its issue tick beats the live one. So the observation
that read the **newer server state wins in both clobber directions**,
regardless of commit order. Checks-only carry-overs and failed fetches
don't compete (they keep the live gate rather than blanking it).

`prGateSeq` is pruned alongside `prGates` at every mutation/reset site.
A documented, bounded, self-healing residual remains (a whole wave
shares one issue instant, so a mid-wave poll beats a late-queued key) —
noted in code; per-key stamping would need a main-actor hop per fetch
and isn't worth it for a transient gate flip.

## Tests

Three deterministic `GatedGitHubAPI` regression tests pinning the
interleavings by issue order:
- `testMergePollGateWriteSurvivesChecksOnlyRepublish` — checks-only
carry-over can't clobber the poll.
- `testLaterIssuedMergePollGateBeatsEarlierIssuedWaveFullFetch` — the
primary #84 trigger; **verified it fails under commit-time stamping**.
- `testLaterIssuedWaveFetchBeatsEarlierMergePollGate` — a fresher wave
fetch beats a staler poll write (guards "poll always wins").

`just check` clean, full suite (269 tests) green. Reviewed over three
rounds (multi-agent + single-agent); final review passed with no
blocking findings.

Closes #84
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant