Skip to content

fix(host-service): replace bulky GraphQL PR query with targeted REST per-head lookups#4291

Merged
Kitenite merged 3 commits into
mainfrom
fix/4246-pr-sidebar-504
May 9, 2026
Merged

fix(host-service): replace bulky GraphQL PR query with targeted REST per-head lookups#4291
Kitenite merged 3 commits into
mainfrom
fix/4246-pr-sidebar-504

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented May 9, 2026

Fixes #4246

Summary

  • Replace the single repo-wide PullRequestsForSidebar GraphQL query (up to 100 PRs + statusCheckRollup per PR in one roundtrip) with targeted REST calls scoped to the upstream branch each workspace actually tracks.
  • Run REST through gh api first (reuses the host-service auth path already proven for workspace creation) and fall back to Octokit on failure.
  • Re-key the in-memory PR cache from per-repo to per (repo, headOwner, headRepo, branch) so multiple workspaces tracking the same upstream branch still collapse to one call per TTL window.
  • Preserve the row's last-known review decision and checks when the per-PR detail refresh fails (transient GitHub failures don't reset the badge to a worse state).
  • Match REST head candidates exactly on (headOwner, headRepo, headRef) so we don't pick the wrong PR when GitHub returns multiple matches.

Why / Context

On large repos with heavy CI, the GraphQL query times out at GitHub's edge with HTTP 504 (~11s) because materializing statusCheckRollup.contexts(first: 50) across 100 PRs in one query exceeds GitHub's deadline. The failed promise is intentionally cached for the full 60s TTL to avoid rate-limit storms — so once the query starts failing, the sidebar PR badge never appears for any workspace until a restart, and even then it fails again on the next refresh.

Issue #4246 captured this with concrete repro: 212 of 214 /graphql requests returned 504, 2 returned 500, zero 200s. Reducing the query to pullRequests(first: 20, states: [OPEN]) without statusCheckRollup returned 200 in ~660ms — confirming the rollup materialization is the cause.

How It Works

  • fetchPullRequestByHeadGET /repos/{owner}/{repo}/pulls?head={ownerLogin}:{branch}&state=all&sort=updated&direction=desc&per_page=10. Targets the workspace's head ref instead of paginating the repo. Multiple candidates are filtered to the exact (headOwner, headRepo, headRef) match (case-insensitive on owner/repo, case-sensitive on branch).
  • fetchPullRequestReviewDecisionGET /repos/{owner}/{repo}/pulls/{number}/reviews, then derive the decision client-side from the latest non-COMMENTED/PENDING review per author (matches the GraphQL reviewDecision semantics).
  • fetchPullRequestChecksGET /commits/{sha}/check-runs and GET /commits/{sha}/statuses in parallel; normalize into the same GitHubCheckContextNode shape the existing parseCheckContexts mapper consumes, so downstream check rollup logic is unchanged.
  • Each function has a *FromGh variant (primary, via execGh) and an Octokit variant (fallback). On gh failure for the head lookup, we log and fall back to Octokit; on gh failure for review/checks, we lazily acquire the Octokit client once per refresh and retry both calls together.
  • Cache key changed from ${owner}/${repo} to ${owner}/${repo}/${headOwner}/${headRepo}/${branch}. The TTL behavior (failures cached for full TTL to prevent retry storms) is preserved.
  • When both gh and Octokit fail for the per-PR detail refresh, we leave checksByNumber/reviewDecisionByNumber un-set for that PR. The upsert then sources reviewDecision and checks from the existing DB row instead of writing empty values, so the badge stays at its last-known state until a refresh succeeds.

Manual QA Checklist

Happy path

  • On a workspace whose upstream branch has an open PR, the sidebar PR badge appears within one refresh tick.
  • PR state (open / draft / merged / closed), review decision, and checks status all match gh pr view for the same PR.
  • Cross-repo (fork) PRs are detected and isCrossRepository is set correctly.

Large-repo / 504 regression

Cache + multi-workspace

  • Two workspaces tracking the same upstream branch only trigger one REST roundtrip per TTL window (verify in host-service log).
  • Two workspaces tracking different branches in the same repo each get their own roundtrip.

Failure / fallback

  • Force a gh failure (e.g., revoke gh auth) — Octokit fallback fires and the badge still resolves.
  • If both fail on the per-PR detail call, the row keeps its previous review decision + checks (badge does not flap to an empty state).
  • If both fail on the head call, the failure is cached for the TTL (no retry storm) and recovers at the next TTL boundary.

Multi-candidate head match

  • When GitHub returns multiple PRs for the same head ref (e.g. fork + base, or closed + open), the row reflects the upstream the workspace actually tracks.

Testing

  • bun run typecheck (host-service)
  • bun run lint
  • bun test packages/host-service/src/runtime/pull-requests/ — 9 pass (REST normalization, exact head-candidate match, review-decision derivation, check + status rollup mapping, and a regression test that the row's review/checks are preserved when the detail refresh fails)

Design Decisions

  • REST over fixing GraphQL: We can't reduce the GraphQL query enough to be reliably under GitHub's edge timeout while still getting checks + reviews. REST endpoints are individually fast and let us scope to the exact head ref.
  • gh primary, Octokit fallback: gh reuses the host-service auth path already proven for workspace creation and tends to behave better under flaky network conditions; Octokit covers the case where gh is missing or unauthenticated.
  • Per-head cache key: keeps the storm-prevention TTL semantics while still benefiting from collapse when many workspaces track the same upstream branch.
  • Preserve last-known state on partial failure: a transient failure on the detail call is not evidence that the PR is approval-less or check-less. Sourcing those fields from the existing row keeps the badge stable across blips.

Risks / Rollout

  • Risk: REST calls are now fanned out per workspace head ref. For projects with many workspaces tracking distinct branches, this is more roundtrips than the old single GraphQL — but each is small and parallel, and the cache collapses duplicates. The previous query was already failing on these repos, so net availability strictly improves.
  • Rollback: revert the two commits on this branch; the GraphQL query path is fully removed but the file history makes restoration trivial.

Summary by CodeRabbit

  • Refactor

    • PR syncing now resolves and caches by upstream head/branch for more accurate, targeted refreshes.
    • Switched PR detail lookups to REST-first queries with a CLI-assisted fallback for review and checks data.
  • Stability

    • Partial failures during detail refresh preserve existing workspace links and prior review/check state.
  • Tests

    • Expanded tests for head-based PR lookup, review-decision extraction, and combined checks handling.

Review Change Stack

…per-head lookups

The GraphQL `PullRequestsForSidebar` query that fetched up to 100 PRs
plus their `statusCheckRollup` in a single roundtrip times out (504) on
large repos with heavy CI. Failures are cached for the full TTL, so the
sidebar PR badge never resolves.

Switch to per-upstream-branch REST queries (PR by head + reviews +
checks/statuses), executed via `gh` with an Octokit fallback. Cache key
is now (repo, head) so multiple workspaces tracking the same upstream
branch still collapse to one call per TTL window.

Fixes #4246
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 25d5dd0d-7cbe-475a-b9ff-72ee84bc7a54

📥 Commits

Reviewing files that changed from the base of the PR and between 65c11f3 and 6912677.

📒 Files selected for processing (1)
  • packages/host-service/src/runtime/pull-requests/pull-requests.test.ts

📝 Walkthrough

Walkthrough

Replaces a repository-wide GraphQL PR query with per-head REST lookups (gh CLI first, Octokit fallback), adds GitHub* types and runtime normalization, implements per-head caching, wires execGh through the manager/app, and adds tests for REST helpers and failure-preservation behavior.

Changes

REST-Based Pull Request Fetching and Per-Head Caching

Layer / File(s) Summary
GitHub Type System
packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts, packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts
New GitHub* types replace GraphQL* equivalents and PULL_REQUESTS_QUERY GraphQL constant was removed.
REST Response Parsing
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
Runtime-safe parsers validate REST PR objects, normalize PR state, derive review decision per author, and convert check-runs/statuses to unified check context nodes.
REST/GH Fetch Helpers
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
Six new helpers: fetchPullRequestByHeadFromGh/fetchPullRequestByHead, fetchPullRequestReviewDecisionFromGh/fetchPullRequestReviewDecision, fetchPullRequestChecksFromGh/fetchPullRequestChecks (gh CLI first, Octokit fallback).
Module Exports
packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts
Replaces single fetchRepositoryPullRequests export with specific per-aspect helpers and re-exports GitHub* types.
Manager Integration
packages/host-service/src/runtime/pull-requests/pull-requests.ts
Adds required execGh to PullRequestRuntimeManagerOptions, introduces per-head pullRequestHeadCache, implements getCachedPullRequestByHead with TTL, and refactors fetchRepoPullRequests to resolve PRs per head ref.
Type Updates
packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/pull-request-mappers.ts
Adjusts mappers to accept GitHub* types (mapPullRequestState, mapReviewDecision, parseCheckContexts, recency helpers).
Wiring & Tests
packages/host-service/src/app.ts, packages/host-service/src/runtime/pull-requests/*test.ts, packages/host-service/src/runtime/pull-requests/utils/github-query/*test.ts
Wires execGh into app and manager; refactors tests to accept execGh overrides; adds tests for gh-based PR-by-head, review-decision derivation, checks aggregation, and preservation-on-detail-refresh-failure.

Sequence Diagram(s)

sequenceDiagram
  participant Manager
  participant ExecGh
  participant Octokit
  participant DB
  Manager->>ExecGh: fetch PRs by head (gh api)
  alt ExecGh returns PR
    ExecGh-->>Manager: PR node
  else ExecGh fails
    Manager->>Octokit: octokit.rest pulls.list / listReviews / checks.listForRef
    Octokit-->>Manager: PR node / reviews / checks
  end
  Manager->>DB: upsert pull_requests (use fetched reviewDecision/checks)
  DB-->>Manager: ack
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • superset-sh/superset#4140: Adds and wires an injectable execGh implementation and updates gh-first PR/issue lookup wiring, touching overlapping host-service execGh and PR lookup codepaths.

"🐰 I sniffed the CLI and hopped to see—
Per-head RESTs now set PRs free,
gh and Octokit hand in paw,
Badges glow and tests applaud! 🥕"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: replacing GraphQL PR queries with targeted REST per-head lookups to fix timeout issues.
Description check ✅ Passed The PR description includes all required sections: summary of changes, detailed explanation of the approach, testing verification, and manual QA checklist with comprehensive coverage.
Linked Issues check ✅ Passed The PR fully addresses issue #4246 by replacing the failing GraphQL query with targeted REST calls per-head that avoid timeouts, implement fallback logic, and preserve state on partial failures.
Out of Scope Changes check ✅ Passed All changes are focused on replacing GraphQL PR lookups with REST per-head queries and supporting infrastructure; no unrelated modifications were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/4246-pr-sidebar-504

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 9, 2026

Greptile Summary

Replaces the single repo-wide PullRequestsForSidebar GraphQL query (which was timing out at GitHub's edge with HTTP 504 on large repos) with targeted REST calls scoped to each workspace's upstream head ref, using gh api as the primary path and Octokit as fallback.

  • Head-scoped lookup: GET /repos/{owner}/{repo}/pulls?head={owner}:{branch}&per_page=1 replaces the 100-PR GraphQL fetch, eliminating statusCheckRollup materialization as the timeout cause.
  • Cache re-keyed: pullRequestHeadCache now uses owner/repo/headOwner/headRepo/branch instead of owner/repo, collapsing multiple workspaces tracking the same upstream branch into one API call per TTL window.
  • Separate review/check calls: fetchPullRequestReviewDecision* and fetchPullRequestChecks* replace the embedded GraphQL fields, each with a gh-primary / Octokit-fallback pattern, and a complete failure path that logs and continues.

Confidence Score: 3/5

The core fix is sound but a partial failure mode in fetchRepoPullRequests can silently overwrite a previously valid review decision with null in the database.

When both gh and Octokit fail for the secondary review/check calls, reviewDecisionByNumber has no entry for that PR number. The upsert still runs and unconditionally writes null to the reviewDecision column, clearing any previously stored approved or changes_requested value — a visible regression for users who hit transient API failures. The rest of the change is well-structured and the fallback pattern is consistent.

packages/host-service/src/runtime/pull-requests/pull-requests.ts — specifically the innermost catch block in fetchRepoPullRequests and the reviewDecision line in the upsert loop.

Important Files Changed

Filename Overview
packages/host-service/src/runtime/pull-requests/pull-requests.ts Core runtime rewritten to use per-head REST lookups; contains a logic gap where a complete review/check API failure during upsert silently clears the stored review decision.
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts New REST helpers for head lookup, review decision derivation, and check/status normalization; unpaginated 100-item caps on reviews and check-runs are a minor concern for heavy PRs.
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts New test file covering REST normalization, review decision derivation, and check+status rollup mapping; good coverage of the happy-path shapes.
packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts Type renames from GraphQL to GitHub prefix; removes embedded review/check fields from the PR node type and lifts them to standalone types.
packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/pull-request-mappers.ts Type annotation updates only; no logic changes.
packages/host-service/src/runtime/pull-requests/pull-requests.test.ts Test scaffolding updated with a no-op execGh stub for the direct-PR-linking path.
packages/host-service/src/app.ts Minimal wiring change to pass execGh into PullRequestRuntimeManager.

Sequence Diagram

sequenceDiagram
    participant PM as PullRequestRuntimeManager
    participant GH as gh api
    participant OK as Octokit
    participant DB as SQLite DB

    PM->>PM: Build wantedRefs per workspace head ref
    loop For each head ref
        PM->>GH: "GET /repos/owner/repo/pulls?head=owner:branch&per_page=1"
        alt gh fails
            PM->>OK: octokit.rest.pulls.list
        end
        PM->>PM: Cache result in pullRequestHeadCache (60s TTL)
    end
    loop For each PR node found
        PM->>GH: GET /pulls/number/reviews
        PM->>GH: GET /commits/sha/check-runs and /statuses
        alt gh fails
            PM->>OK: listReviews + checks.listForRef
        end
    end
    PM->>DB: upsertPullRequestRow with state + reviewDecision + checksStatus
Loading

Comments Outside Diff (1)

  1. packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts, line 817-827 (link)

    P2 Review decision truncated at 100 items without pagination

    Both the gh and Octokit review-fetching paths request per_page=100 but perform no pagination. The GitHub REST reviews API returns reviews in chronological (oldest-first) order, so on a PR with more than 100 reviews the most recent reviews per author — the ones that determine the actual review decision — will silently be absent. mapReviewDecision would then derive the decision from stale older reviews, potentially showing "changes_requested" for an author who later approved. This is a low-probability edge case but the same applies to the check-runs endpoint (fetchPullRequestChecksFromGh / fetchPullRequestChecks) which is equally unpaginated.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
    Line: 817-827
    
    Comment:
    **Review decision truncated at 100 items without pagination**
    
    Both the `gh` and Octokit review-fetching paths request `per_page=100` but perform no pagination. The GitHub REST reviews API returns reviews in chronological (oldest-first) order, so on a PR with more than 100 reviews the most recent reviews per author — the ones that determine the actual review decision — will silently be absent. `mapReviewDecision` would then derive the decision from stale older reviews, potentially showing "changes_requested" for an author who later approved. This is a low-probability edge case but the same applies to the check-runs endpoint (`fetchPullRequestChecksFromGh` / `fetchPullRequestChecks`) which is equally unpaginated.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/host-service/src/runtime/pull-requests/pull-requests.ts:997-1010
**Stale review decision written on partial failure**

When both `gh` and Octokit fail for review/check fetching, `reviewDecisionByNumber` is never populated for that PR. The upsert loop then resolves `reviewDecisionByNumber.get(node.number) ?? null` to `null`, and `mapReviewDecision(null)` returns `null`. `upsertPullRequestRow` unconditionally overwrites the stored row with this `null` value — so a PR that previously showed "approved" in the sidebar will silently flip to "no review decision" after any transient API failure that affects only the secondary calls, even though the head-lookup succeeded and the row is being updated. The fix is to fall back to `coerceReviewDecision(existing?.reviewDecision ?? null)` when `reviewDecisionByNumber` has no entry for the PR number.

### Issue 2 of 2
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts:817-827
**Review decision truncated at 100 items without pagination**

Both the `gh` and Octokit review-fetching paths request `per_page=100` but perform no pagination. The GitHub REST reviews API returns reviews in chronological (oldest-first) order, so on a PR with more than 100 reviews the most recent reviews per author — the ones that determine the actual review decision — will silently be absent. `mapReviewDecision` would then derive the decision from stale older reviews, potentially showing "changes_requested" for an author who later approved. This is a low-probability edge case but the same applies to the check-runs endpoint (`fetchPullRequestChecksFromGh` / `fetchPullRequestChecks`) which is equally unpaginated.

Reviews (1): Last reviewed commit: "fix(host-service): replace bulky GraphQL..." | Re-trigger Greptile

Comment on lines +997 to +1010
} catch (error) {
console.warn(
"[host-service:pull-request-runtime] Failed to fetch PR review/check state",
{
projectId,
owner: repo.owner,
name: repo.name,
prNumber: node.number,
ghError,
error,
},
);
checksByNumber.set(node.number, []);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale review decision written on partial failure

When both gh and Octokit fail for review/check fetching, reviewDecisionByNumber is never populated for that PR. The upsert loop then resolves reviewDecisionByNumber.get(node.number) ?? null to null, and mapReviewDecision(null) returns null. upsertPullRequestRow unconditionally overwrites the stored row with this null value — so a PR that previously showed "approved" in the sidebar will silently flip to "no review decision" after any transient API failure that affects only the secondary calls, even though the head-lookup succeeded and the row is being updated. The fix is to fall back to coerceReviewDecision(existing?.reviewDecision ?? null) when reviewDecisionByNumber has no entry for the PR number.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/runtime/pull-requests/pull-requests.ts
Line: 997-1010

Comment:
**Stale review decision written on partial failure**

When both `gh` and Octokit fail for review/check fetching, `reviewDecisionByNumber` is never populated for that PR. The upsert loop then resolves `reviewDecisionByNumber.get(node.number) ?? null` to `null`, and `mapReviewDecision(null)` returns `null`. `upsertPullRequestRow` unconditionally overwrites the stored row with this `null` value — so a PR that previously showed "approved" in the sidebar will silently flip to "no review decision" after any transient API failure that affects only the secondary calls, even though the head-lookup succeeded and the row is being updated. The fix is to fall back to `coerceReviewDecision(existing?.reviewDecision ?? null)` when `reviewDecisionByNumber` has no entry for the PR number.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 9, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts (1)

24-217: 💤 Low value

Tests look good; one optional gap.

The argument-list assertions are nice and tight, and they pin both the endpoint shape and the per_page/sort defaults. One easy-to-add case worth considering: a closed/merged PR with only an APPROVED review (covers the prState !== "OPEN" → null branch in mapReviewDecision), since the current “no terminal review” test only exercises the OPEN→REVIEW_REQUIRED branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts`
around lines 24 - 217, Add a test that covers the non-OPEN PR branch in
mapReviewDecision by calling fetchPullRequestReviewDecisionFromGh with a closed
or merged state (e.g., "CLOSED" or "MERGED") and a single REST review payload
containing only an APPROVED review; assert the returned decision is null and
that the execGh call used the same reviews endpoint
("repos/.../pulls/42/reviews") with per_page=100 so the behavior for prState !==
"OPEN" is exercised and verified.
packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts (1)

111-144: 💤 Low value

Review-decision derivation looks correct, with one tiny edge case.

The "latest non-COMMENTED/non-PENDING per author, then CHANGES_REQUESTED-wins-over-APPROVED" logic matches GitHub's documented semantics for everything except branch-protection "required reviewers" (which is unavoidable without GraphQL). One minor edge case at line 129: Date.parse(review.submitted_at ?? "") is NaN when missing, and NaN > NaN / NaN > anyNumber are both false, so a later review with a missing submitted_at will not replace an earlier one even if order suggests it should. In practice GitHub always populates submitted_at for non-PENDING reviews, so this is theoretical — flagging only because the comparison silently drops candidates rather than falling back to insertion order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts`
around lines 111 - 144, The comparison using Date.parse(review.submitted_at ??
"") can produce NaN and silently prevent later reviews from replacing earlier
ones; update mapReviewDecision to track the iteration index and compare reviews
by numeric timestamp first (use Date.parse(...) and treat invalid parse as NaN)
but break ties / fallback to the iteration sequence so a later array item wins
when timestamps are equal/invalid. Concretely, while iterating
asArray(rawReviews) keep a monotonic counter (e.g., seq) and when deciding to
replace an existing review in latestByAuthor compare parsed timestamps and if
either is NaN or timestamps are equal use the seq to prefer the later item;
reference mapReviewDecision, latestByAuthor, and review.submitted_at to locate
the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/host-service/src/runtime/pull-requests/pull-requests.ts`:
- Around line 925-1013: The concurrent gh subprocess fan-out is unbounded
because Promise.all maps over wantedRefs and latestByKey directly; introduce a
concurrency limiter (e.g., p-limit(8) or a simple semaphore) and wrap the
per-item async work so only a fixed number run at once. Apply the limiter when
mapping Array.from(wantedRefs.entries()).map(...) that calls
getCachedPullRequestByHead and when mapping
Array.from(latestByKey.values()).map(...) that calls
fetchPullRequestReviewDecisionFromGh and fetchPullRequestChecksFromGh (and their
octokit fallbacks fetchPullRequestReviewDecision/fetchPullRequestChecks),
returning limiter(() => /* original async block */) so Promise.all awaits
bounded tasks; keep octokitPromise usage the same but ensure octokit creation
happens inside the limited tasks.

---

Nitpick comments:
In
`@packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts`:
- Around line 24-217: Add a test that covers the non-OPEN PR branch in
mapReviewDecision by calling fetchPullRequestReviewDecisionFromGh with a closed
or merged state (e.g., "CLOSED" or "MERGED") and a single REST review payload
containing only an APPROVED review; assert the returned decision is null and
that the execGh call used the same reviews endpoint
("repos/.../pulls/42/reviews") with per_page=100 so the behavior for prState !==
"OPEN" is exercised and verified.

In
`@packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts`:
- Around line 111-144: The comparison using Date.parse(review.submitted_at ??
"") can produce NaN and silently prevent later reviews from replacing earlier
ones; update mapReviewDecision to track the iteration index and compare reviews
by numeric timestamp first (use Date.parse(...) and treat invalid parse as NaN)
but break ties / fallback to the iteration sequence so a later array item wins
when timestamps are equal/invalid. Concretely, while iterating
asArray(rawReviews) keep a monotonic counter (e.g., seq) and when deciding to
replace an existing review in latestByAuthor compare parsed timestamps and if
either is NaN or timestamps are equal use the seq to prefer the later item;
reference mapReviewDecision, latestByAuthor, and review.submitted_at to locate
the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0803bdab-ce62-4462-92fe-1fcf18d1891f

📥 Commits

Reviewing files that changed from the base of the PR and between 4036896 and 1bea099.

📒 Files selected for processing (9)
  • packages/host-service/src/app.ts
  • packages/host-service/src/runtime/pull-requests/pull-requests.test.ts
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts
  • packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/pull-request-mappers.ts
💤 Files with no reviewable changes (1)
  • packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts

Comment on lines +925 to +1013
await Promise.all(
Array.from(wantedRefs.entries()).map(async ([key, head]) => {
try {
const node = await this.getCachedPullRequestByHead(
repo,
head,
options,
);
if (!node) return;

const nodeKey = upstreamKey(
node.headRepositoryOwner?.login ?? null,
node.headRepository?.name ?? null,
node.headRefName,
);
if (nodeKey === key) latestByKey.set(key, node);
} catch (error) {
console.warn(
"[host-service:pull-request-runtime] Failed to fetch PR by head",
{
projectId,
owner: repo.owner,
name: repo.name,
head,
error,
},
);
}
}),
);

const keyToRow = new Map<string, { id: string }>();
const now = Date.now();

const checksByNumber = new Map<
number,
Awaited<ReturnType<typeof fetchPullRequestChecks>>
>();
const reviewDecisionByNumber = new Map<
number,
GitHubPullRequestReviewDecision
>();
let octokitPromise: Promise<Octokit> | null = null;
await Promise.all(
Array.from(latestByKey.values()).map(async (node) => {
try {
const [reviewDecision, checks] = await Promise.all([
fetchPullRequestReviewDecisionFromGh(
this.execGh,
repo,
node.number,
node.state,
),
fetchPullRequestChecksFromGh(this.execGh, repo, node.headRefOid),
]);
reviewDecisionByNumber.set(node.number, reviewDecision);
checksByNumber.set(node.number, checks);
} catch (ghError) {
try {
octokitPromise ??= this.github();
const octokit = await octokitPromise;
const [reviewDecision, checks] = await Promise.all([
fetchPullRequestReviewDecision(
octokit,
repo,
node.number,
node.state,
),
fetchPullRequestChecks(octokit, repo, node.headRefOid),
]);
reviewDecisionByNumber.set(node.number, reviewDecision);
checksByNumber.set(node.number, checks);
} catch (error) {
console.warn(
"[host-service:pull-request-runtime] Failed to fetch PR review/check state",
{
projectId,
owner: repo.owner,
name: repo.name,
prNumber: node.number,
ghError,
error,
},
);
checksByNumber.set(node.number, []);
}
}
}),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Bound the gh subprocess fan-out per refresh.

performProjectRefresh issues Promise.all over every wanted head (line 925) and then again over every matched PR (line 968), where each PR triggers three concurrent execGh calls (review + check-runs + statuses). For a project with N distinct upstream branches that's roughly N + 3N concurrent gh subprocess spawns per refresh tick, multiplied across projects on the safety-net interval. On a machine tracking many workspaces this can saturate the OS process table and trip GitHub secondary rate limits (the same class of failure the 60s TTL was designed to dampen, but at the gh-process layer instead of the HTTP layer).

Consider a small concurrency limiter (e.g., p-limit(8) — or a hand-rolled semaphore if you don't want to add a dep) wrapping the per-head and per-PR work, so the fan-out stays bounded regardless of project size.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service/src/runtime/pull-requests/pull-requests.ts` around
lines 925 - 1013, The concurrent gh subprocess fan-out is unbounded because
Promise.all maps over wantedRefs and latestByKey directly; introduce a
concurrency limiter (e.g., p-limit(8) or a simple semaphore) and wrap the
per-item async work so only a fixed number run at once. Apply the limiter when
mapping Array.from(wantedRefs.entries()).map(...) that calls
getCachedPullRequestByHead and when mapping
Array.from(latestByKey.values()).map(...) that calls
fetchPullRequestReviewDecisionFromGh and fetchPullRequestChecksFromGh (and their
octokit fallbacks fetchPullRequestReviewDecision/fetchPullRequestChecks),
returning limiter(() => /* original async block */) so Promise.all awaits
bounded tasks; keep octokitPromise usage the same but ensure octokit creation
happens inside the limited tasks.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service/src/runtime/pull-requests/pull-requests.ts">

<violation number="1" location="packages/host-service/src/runtime/pull-requests/pull-requests.ts:941">
P1: Per-head fetch errors are swallowed, which causes transient API failures to clear `workspace.pullRequestId` values during refresh.</violation>

<violation number="2" location="packages/host-service/src/runtime/pull-requests/pull-requests.ts:1030">
P1: On partial failure (both `gh` and Octokit fail for review/check fetching), `reviewDecisionByNumber` is never populated for the affected PR. The fallback `?? null` then feeds `null` into `mapReviewDecision`, which returns `null` and unconditionally overwrites a previously stored review decision (e.g., "approved") in the DB. This causes the sidebar badge to silently flip to "no review decision" after any transient API failure on the secondary calls, even though the head-lookup succeeded. Preserve the existing stored value when the map has no entry for the PR number.</violation>
</file>

<file name="packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts">

<violation number="1" location="packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts:197">
P1: Fetching only one PR for `head=owner:branch` can miss the correct head repository and produce false “no PR” results.</violation>

<violation number="2" location="packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts:254">
P2: Review decision derivation is incorrect for PRs with more than 100 reviews because only the first page is processed.</violation>

<violation number="3" location="packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts:312">
P2: Checks/status rollup can be wrong on commits with more than 100 check runs or statuses because pagination is not handled.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +941 to +952
} catch (error) {
console.warn(
"[host-service:pull-request-runtime] Failed to fetch PR by head",
{
projectId,
owner: repo.owner,
name: repo.name,
head,
error,
},
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Per-head fetch errors are swallowed, which causes transient API failures to clear workspace.pullRequestId values during refresh.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/runtime/pull-requests/pull-requests.ts, line 941:

<comment>Per-head fetch errors are swallowed, which causes transient API failures to clear `workspace.pullRequestId` values during refresh.</comment>

<file context>
@@ -845,62 +870,151 @@ export class PullRequestRuntimeManager {
+						node.headRefName,
+					);
+					if (nodeKey === key) latestByKey.set(key, node);
+				} catch (error) {
+					console.warn(
+						"[host-service:pull-request-runtime] Failed to fetch PR by head",
</file context>
Suggested change
} catch (error) {
console.warn(
"[host-service:pull-request-runtime] Failed to fetch PR by head",
{
projectId,
owner: repo.owner,
name: repo.name,
head,
error,
},
);
}
} catch (error) {
console.warn(
"[host-service:pull-request-runtime] Failed to fetch PR by head",
{
projectId,
owner: repo.owner,
name: repo.name,
head,
error,
},
);
throw error;
}

Comment thread packages/host-service/src/runtime/pull-requests/pull-requests.ts Outdated
number: number,
prState: PullRequestState,
): Promise<GitHubPullRequestReviewDecision> {
const response = await octokit.rest.pulls.listReviews({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Review decision derivation is incorrect for PRs with more than 100 reviews because only the first page is processed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts, line 254:

<comment>Review decision derivation is incorrect for PRs with more than 100 reviews because only the first page is processed.</comment>

<file context>
@@ -1,26 +1,336 @@
+	number: number,
+	prState: PullRequestState,
+): Promise<GitHubPullRequestReviewDecision> {
+	const response = await octokit.rest.pulls.listReviews({
+		owner: repository.owner,
+		repo: repository.name,
</file context>

headSha: string,
): Promise<GitHubCheckContextNode[]> {
const [checkRunsResponse, statusesResponse] = await Promise.all([
octokit.rest.checks.listForRef({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Checks/status rollup can be wrong on commits with more than 100 check runs or statuses because pagination is not handled.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts, line 312:

<comment>Checks/status rollup can be wrong on commits with more than 100 check runs or statuses because pagination is not handled.</comment>

<file context>
@@ -1,26 +1,336 @@
+	headSha: string,
+): Promise<GitHubCheckContextNode[]> {
+	const [checkRunsResponse, statusesResponse] = await Promise.all([
+		octokit.rest.checks.listForRef({
 			owner: repository.owner,
 			repo: repository.name,
</file context>

…nd match head candidates exactly

- When `gh`/Octokit fail on the per-PR review/checks lookup (but the
  head lookup succeeded), keep the previously-stored `reviewDecision`
  and `checks` instead of clearing them. Avoids the badge flapping to
  "no checks" / "review required" on transient GitHub failures.
- REST `pulls?head=` can return multiple PRs sharing the same head ref
  (e.g. across forks or closed/open states). Fetch up to 10 candidates
  and match exactly on `(headOwner, headRepo, headRef)`, with
  case-insensitive owner/repo comparison, before normalizing.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/host-service/src/runtime/pull-requests/pull-requests.test.ts (1)

227-233: 💤 Low value

Console suppression pattern is acceptable.

The console.warn suppression keeps test output clean now that refreshPullRequestsByWorkspaces uses execGh-based queries that may log warnings. The pattern correctly restores the original in a finally block.

♻️ Optional: Extract a helper to reduce repetition

This pattern appears 3 times in the file. You could extract a helper like:

async function withSuppressedWarnings<T>(fn: () => Promise<T>): Promise<T> {
  const originalWarn = console.warn;
  console.warn = () => {};
  try {
    return await fn();
  } finally {
    console.warn = originalWarn;
  }
}

Then use it as:

await withSuppressedWarnings(() =>
  manager.refreshPullRequestsByWorkspaces([WORKSPACE_ID])
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service/src/runtime/pull-requests/pull-requests.test.ts` around
lines 227 - 233, Extract a small test helper to DRY the console.warn
suppression: add an exported or file-scoped async function
withSuppressedWarnings<T>(fn: () => Promise<T>): Promise<T> that saves
console.warn, replaces it with a no-op, awaits fn() in a try block and restores
the original console.warn in finally; then replace the three inline suppression
blocks (calls around manager.refreshPullRequestsByWorkspaces and similar calls)
with await withSuppressedWarnings(() =>
manager.refreshPullRequestsByWorkspaces([WORKSPACE_ID])) (or the corresponding
call) so each site uses the new helper and restoration logic is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/host-service/src/runtime/pull-requests/pull-requests.test.ts`:
- Around line 227-233: Extract a small test helper to DRY the console.warn
suppression: add an exported or file-scoped async function
withSuppressedWarnings<T>(fn: () => Promise<T>): Promise<T> that saves
console.warn, replaces it with a no-op, awaits fn() in a try block and restores
the original console.warn in finally; then replace the three inline suppression
blocks (calls around manager.refreshPullRequestsByWorkspaces and similar calls)
with await withSuppressedWarnings(() =>
manager.refreshPullRequestsByWorkspaces([WORKSPACE_ID])) (or the corresponding
call) so each site uses the new helper and restoration logic is centralized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a52da55d-7283-4af6-a454-b590909cf3ea

📥 Commits

Reviewing files that changed from the base of the PR and between 1bea099 and 65c11f3.

📒 Files selected for processing (4)
  • packages/host-service/src/runtime/pull-requests/pull-requests.test.ts
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.test.ts
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts
  • packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service/src/runtime/pull-requests/pull-requests.ts">

<violation number="1" location="packages/host-service/src/runtime/pull-requests/pull-requests.ts:1016">
P1: When review/check fetch fails, this fallback can attach stale checks/review data to a new `headSha`, causing incorrect PR badge/status for the current commit.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +1016 to +1021
const checks = checksByNumber.has(node.number)
? parseCheckContexts(checksByNumber.get(node.number) ?? [])
: parseChecksJson(existing?.checksJson ?? null);
const reviewDecision = reviewDecisionByNumber.has(node.number)
? mapReviewDecision(reviewDecisionByNumber.get(node.number) ?? null)
: coerceReviewDecision(existing?.reviewDecision ?? null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When review/check fetch fails, this fallback can attach stale checks/review data to a new headSha, causing incorrect PR badge/status for the current commit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/runtime/pull-requests/pull-requests.ts, line 1016:

<comment>When review/check fetch fails, this fallback can attach stale checks/review data to a new `headSha`, causing incorrect PR badge/status for the current commit.</comment>

<file context>
@@ -1006,15 +1006,19 @@ export class PullRequestRuntimeManager {
 		for (const [key, node] of latestByKey) {
 			const existing = this.findPullRequestRow(repo, node.number);
-			const checks = parseCheckContexts(checksByNumber.get(node.number) ?? []);
+			const checks = checksByNumber.has(node.number)
+				? parseCheckContexts(checksByNumber.get(node.number) ?? [])
+				: parseChecksJson(existing?.checksJson ?? null);
</file context>
Suggested change
const checks = checksByNumber.has(node.number)
? parseCheckContexts(checksByNumber.get(node.number) ?? [])
: parseChecksJson(existing?.checksJson ?? null);
const reviewDecision = reviewDecisionByNumber.has(node.number)
? mapReviewDecision(reviewDecisionByNumber.get(node.number) ?? null)
: coerceReviewDecision(existing?.reviewDecision ?? null);
const sameHead =
existing?.headSha.toLowerCase() === node.headRefOid.toLowerCase();
const checks = checksByNumber.has(node.number)
? parseCheckContexts(checksByNumber.get(node.number) ?? [])
: sameHead
? parseChecksJson(existing?.checksJson ?? null)
: [];
const reviewDecision = reviewDecisionByNumber.has(node.number)
? mapReviewDecision(reviewDecisionByNumber.get(node.number) ?? null)
: sameHead
? coerceReviewDecision(existing?.reviewDecision ?? null)
: coerceReviewDecision(null);

…king tests

These two cases exercise the local-DB lookup path and never reach the
GitHub fetch branches that emit warnings, so the wrappers were dead.
@Kitenite Kitenite merged commit 9217867 into main May 9, 2026
16 checks passed
@Kitenite Kitenite deleted the fix/4246-pr-sidebar-504 branch May 9, 2026 21:34
@github-actions github-actions Bot mentioned this pull request May 9, 2026
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.

host-service: PR sidebar badge never appears on large repos because GraphQL PullRequestsForSidebar query times out (504)

1 participant