[Bug]: Pull request sync runs all projects' gh lookups from one directory, breaking per-account gh setups#14123

opened28d agobyJjcardama

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use two GitHub accounts, each able to see a different set of private repositories. Account A sees org-a/*; account B sees org-b/*. Neither can see the other's repositories.
  2. Select the account per repository with a small gh wrapper on PATH that sets GH_CONFIG_DIR from the working directory. Each account has its own config directory, so gh run from an org-a checkout authenticates as A, and from an org-b checkout as B.
  3. Open projects for repositories from both orgs, with threads that have linked pull requests.

Expected behavior

Pull request lookups for a thread run in that thread's workspace, the way they did in 0.0.43-nightly.20260925.2269. Each lookup then authenticates as the account that can see the repository, and pull request status and the pull request pane load for every project.

Actual behavior

In 0.0.43-nightly.20260928.2375, pull request sync runs every project's lookups from one working directory. It appears to be the first project's workspace, unrelated to the thread. I sampled the spawned processes:

  • gh pr view <n> --repo github.com/org-b/<repo> --json ... runs from the org-a project's directory, so it authenticates as A and fails with Could not resolve to a Repository.
  • gh api graphql with a PullRequestSummaries query batches repositories from both orgs into one query (s0: repository(owner: "org-b", ...), s1: repository(owner: "org-a", ...)). No single account can answer that query. Under either account, GitHub returns partial data plus a NOT_FOUND error, and gh exits 1.

The server logs pull request sync skipped for every pull request in the other org each minute, and the pull request pane shows "Could not load pull requests. Pull request operation detail failed: GitHub CLI command failed."

Impact

Anyone who separates GitHub accounts per client or organization (for example with GH_CONFIG_DIR per directory, or with gh configured differently per checkout) loses pull request status and the pull request pane for every repository the directory T3 picked can't see. The only workarounds are giving one account access to every org, which defeats the separation, or routing the account from the command's arguments instead of the working directory.

Version or commit

Regressed between 0.0.43-nightly.20260925.2269 (works) and 0.0.43-nightly.20260928.2375 (fails). Possibly related to the pull request sync changes in #13704 or #13691, but I haven't bisected.

Environment

Linux x64, server run as a systemd user service, gh 2.x.

Logs or stack traces

text
1[17:43:25.899] WARN (#513610): pull request sync skipped
2 { key: 'github.com/org-b/repo-x#15' }

A mixed-owner query reproduces the partial failure directly under either account:

text
1$ gh api graphql -f query='query { s0: repository(owner: "org-b", name: "repo-x") { pullRequest(number: 15) { state } } s1: repository(owner: "org-a", name: "repo-y") { pullRequest(number: 41) { state } } }'
2{"data":{"s0":null,"s1":{"pullRequest":{"state":"OPEN"}}},"errors":[{"type":"NOT_FOUND","path":["s0"],...}]}
3gh: Could not resolve to a Repository with the name 'org-b/repo-x'.
4exit status 1

Workaround

Route the account from the repository named in the command (--repo, repos/<owner>/..., GraphQL owner:) rather than the working directory. That restores the per-pull-request lookups. A mixed-owner batched query still fails for one side.

Suggested fix: run lookups from each thread's workspace as before, or batch GraphQL queries per workspace or per credential rather than across all projects.

Triage

Confirmed on current main (d15210cd, which is v0.0.43-nightly.20260928.2375). The mixed-owner PullRequestSummaries query is what this sync does. Not a duplicate of #14113: that failure is an interrupt on first open (All fibers interrupted without error). This one is gh exiting 1.

Background sync asks for every due link together (PullRequestSyncReactor, concurrency 25). Each summary becomes a PullRequestSummaryRead, and those reads are grouped only by host and pinned credential:

1811:1833:apps/server/src/pullRequest/GitHubPullRequestCli.ts
1 const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({
2 key: ({ request, context }) =>
3 JSON.stringify([
4 request.host.toLowerCase(),
5 Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null)
6 ?.credentialFingerprint ?? null,
7 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
8 ]),
9 resolver: (entries) => {
10 const [first] = entries;
11 // ...
12 : graphqlRead({
13 cwd: first.request.cwd,
14 host: first.request.host,
15 operation: "getPullRequestSummary",
16 query,

Sync never pins a credential, so every github.com link that arrives in the 10ms window shares one group. The query runs in entries[0]'s working directory, which is whichever request got there first, not the thread that owns the repository. gh exits 1 when any aliased repository is invisible to that directory's account, the whole document is dropped, and the sweep logs pull request sync skipped with only the key.

A failed document is then supposed to fall back to one gh pr view per entry, using that entry's own cwd. summary sets that cwd from the project requireProject returns. When the link's repository matches the thread's project, that is the thread's checkout. repositoryArgs always passes --repo <host>/<owner>/<name>, so --repo github.com/org-b/... does not by itself mean the target was rewritten.

A gh pr view for org-b whose process cwd is an org-a checkout means the serving project was not org-b. That is the host fallback in requireProject: a GitHub ref may be answered by any checkout on the host, and the last resort is the first non-Azure checkout, with the requested repository written onto it. The pull-request list (readTogether) and listStats do the same thing on purpose (cwd is chunk[0]'s workspace; the search API comment says "any checkout on the host"). Detail is not in the summary batch. Pull request operation detail failed: GitHub CLI command failed is that read's own gh failing in whichever checkout requireProject selected, which is also what the pane renders.

This resolver is unchanged between v0.0.43-nightly.20260925.2269 and 2375. It landed in #13198 (2026-09-22), which is already in 2269. #13704 only stops the sweep from loading threads that have no links. #13691 changes which threads settlement reads, not which directory gh runs in. If 2269 really did run each lookup in the thread checkout, the difference is whether those reads landed in one 10ms batch. The code that collapses them was already there.

Group summary batches by the credential gh auth token returns in that request's cwd (the fingerprint captureVerifiedCredential already computes), and run each query from a cwd that produced it. Do the same for the list and stat reads that take the first workspace in the chunk. A mixed-owner document cannot be split after gh has already failed it.

Synced from GitHub

I reproduced a related case with two organisation-scoped PATs belonging to the same GitHub login: Git HTTPS access works, but T3's saved host token cannot resolve the other organisation's private repository before cloning. #21264 records that reproduction.

#21265 adds an explicitly enabled server option that reuses existing global Git HTTPS helpers and separates native GraphQL summary/watch batches by selected credential and repository. It also keeps operation pins and caches separate when the tokens have the same viewer. This provides a Git-helper route for the mixed-credential boundary described here; it preserves the existing default and does not change per-checkout gh wrapper behavior. The PR includes focused tests and native remote import/PR-read evidence.

Synced from GitHub

+1, still happening on 0.1.1-nightly. Setup: three accounts on github.com (a personal default plus two work accounts for different owners) and one self-hosted GitLab. A gh wrapper earlier on PATH picks the account from the repo owner (-R, then the origin remote). Terminal and git work per repo, but T3 uses one GitHub token per host for every project, so the mobile Pulls tab shows <owner>/<repo> could not be read for the other two owners.

What would fix it: pick the credential per project or per owner (for example an account picker per project in Settings → Source Control), or resolve it in each project's checkout as before the API transport. Ideally the same for GitLab and other forges.