diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts index 90f09953237..60808ff763f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts @@ -1,3 +1,8 @@ +// v1-only. Dies with the v1 UI sunset. Don't evolve this module — v2 already +// resolves PRs via host-service (`packages/host-service/src/runtime/pull-requests` +// backing `git.getPullRequest` + `pullRequests.getByWorkspaces`). Everything +// under `renderer/screens/main/` + `routes/_authenticated/_dashboard/workspace/` +// gets deleted together; no port needed. import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { execGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; @@ -80,7 +85,10 @@ function getForkOwnerPrefix( export function prMatchesLocalBranch( localBranch: string, - pr: Pick, + pr: Pick< + GHPRResponse, + "headRefName" | "headRepositoryOwner" | "isCrossRepository" + >, ): boolean { if (!branchMatchesPR(localBranch, pr.headRefName)) { return false; @@ -88,6 +96,9 @@ export function prMatchesLocalBranch( const ownerPrefix = getForkOwnerPrefix(localBranch, pr.headRefName); if (!ownerPrefix) { + // Without a fork-owner prefix in the local branch, a cross-fork PR whose + // headRefName collides (e.g. fork:main → base:main) would misattribute. + if (pr.isCrossRepository) return false; return localBranch === pr.headRefName; } diff --git a/packages/host-service/drizzle/0003_workspace_upstream_ref.sql b/packages/host-service/drizzle/0003_workspace_upstream_ref.sql new file mode 100644 index 00000000000..f6c95acf5ba --- /dev/null +++ b/packages/host-service/drizzle/0003_workspace_upstream_ref.sql @@ -0,0 +1,5 @@ +DROP INDEX `workspaces_branch_idx`;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `upstream_owner` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `upstream_repo` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `upstream_branch` text;--> statement-breakpoint +CREATE INDEX `workspaces_upstream_ref_idx` ON `workspaces` (`upstream_owner`,`upstream_repo`,`upstream_branch`); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0003_snapshot.json b/packages/host-service/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000000..b3b8a6302f4 --- /dev/null +++ b/packages/host-service/drizzle/meta/0003_snapshot.json @@ -0,0 +1,493 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d4171f78-422d-48d1-97c4-b803ed17fea9", + "prevId": "a2434e05-3865-4783-9247-7bda589c8806", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index 348757a3259..a54b39dfebf 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775239013772, "tag": "0002_add_terminal_sessions", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1776814372609, + "tag": "0003_workspace_upstream_ref", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index 4ae289694fd..416af39df73 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -102,6 +102,9 @@ export const workspaces = sqliteTable( worktreePath: text("worktree_path").notNull(), branch: text().notNull(), headSha: text("head_sha"), + upstreamOwner: text("upstream_owner"), + upstreamRepo: text("upstream_repo"), + upstreamBranch: text("upstream_branch"), pullRequestId: text("pull_request_id").references(() => pullRequests.id, { onDelete: "set null", }), @@ -111,7 +114,11 @@ export const workspaces = sqliteTable( }, (table) => [ index("workspaces_project_id_idx").on(table.projectId), - index("workspaces_branch_idx").on(table.branch), + index("workspaces_upstream_ref_idx").on( + table.upstreamOwner, + table.upstreamRepo, + table.upstreamBranch, + ), index("workspaces_pull_request_id_idx").on(table.pullRequestId), ], ); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index af89211a87f..0a33bed5740 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -67,6 +67,96 @@ async function getHeadSha(git: Awaited>) { } } +// `pushRemote` / `branch.remote` accept a remote name or a URL. +async function resolveRemoteValueToUrl( + git: Awaited>, + value: string, +): Promise { + if (/^(https?:|git@|ssh:)/.test(value)) return value; + try { + const url = await git.remote(["get-url", value]); + return typeof url === "string" ? url.trim() || null : null; + } catch { + return null; + } +} + +async function resolveWorkspaceUpstream( + git: Awaited>, + localBranch: string, +): Promise<{ owner: string; name: string; branch: string } | null> { + // `@{push}` resolves remote+branch respecting all config precedence in one call. + const pushRef = await tryRaw(git, [ + "rev-parse", + "--abbrev-ref", + `${localBranch}@{push}`, + ]); + if (pushRef) { + const slash = pushRef.indexOf("/"); + if (slash > 0) { + const url = await resolveRemoteValueToUrl(git, pushRef.slice(0, slash)); + const parsed = url ? parseGitHubRemote(url) : null; + if (parsed) { + return { + owner: parsed.owner, + name: parsed.name, + branch: pushRef.slice(slash + 1), + }; + } + } + } + + // Fallback when `@{push}` isn't configured — mirrors gh's config chain. + // Require `branch..merge`; without it, `remote.pushDefault` alone would + // re-open the same-name collision hole on untracked branches. + const mergeRef = await tryConfig(git, `branch.${localBranch}.merge`); + const trackedBranch = mergeRef?.replace(/^refs\/heads\//, ""); + if (!trackedBranch) return null; + + const remoteValue = + (await tryConfig(git, `branch.${localBranch}.pushRemote`)) ?? + (await tryConfig(git, "remote.pushDefault")) ?? + (await tryConfig(git, `branch.${localBranch}.remote`)); + if (!remoteValue) return null; + + const url = await resolveRemoteValueToUrl(git, remoteValue); + const parsed = url ? parseGitHubRemote(url) : null; + if (!parsed) return null; + + // `gh pr checkout` renames the local branch on collision (`main` → + // `quueli-main`) but the PR's headRefName stays `main`, so we key on the + // tracked remote branch, not the local name. + return { owner: parsed.owner, name: parsed.name, branch: trackedBranch }; +} + +async function tryRaw( + git: Awaited>, + args: string[], +): Promise { + try { + return (await git.raw(args)).trim() || null; + } catch { + return null; + } +} + +async function tryConfig( + git: Awaited>, + key: string, +): Promise { + return tryRaw(git, ["config", "--get", key]); +} + +function upstreamKey( + owner: string | null, + repo: string | null, + branch: string, +): string | null { + if (!owner || !repo) return null; + // GitHub owner/repo are case-insensitive; branch names are case-sensitive. + return `${owner.toLowerCase()}/${repo.toLowerCase()}#${branch}`; +} + type RepoProvider = "github"; export interface PullRequestStateSnapshot { @@ -213,8 +303,18 @@ export class PullRequestRuntimeManager { continue; } const headSha = await getHeadSha(git); - - if (branch === workspace.branch && headSha === workspace.headSha) { + const upstream = await resolveWorkspaceUpstream(git, branch); + const upstreamOwner = upstream?.owner ?? null; + const upstreamRepo = upstream?.name ?? null; + const upstreamBranch = upstream?.branch ?? null; + + if ( + branch === workspace.branch && + headSha === workspace.headSha && + upstreamOwner === workspace.upstreamOwner && + upstreamRepo === workspace.upstreamRepo && + upstreamBranch === workspace.upstreamBranch + ) { continue; } @@ -223,6 +323,9 @@ export class PullRequestRuntimeManager { .set({ branch, headSha, + upstreamOwner, + upstreamRepo, + upstreamBranch, }) .where(eq(workspaces.id, workspace.id)) .run(); @@ -309,22 +412,32 @@ export class PullRequestRuntimeManager { .all(); if (projectWorkspaces.length === 0) return; - const branchNames = [ - ...new Set(projectWorkspaces.map((workspace) => workspace.branch)), - ]; - const branchToPullRequest = await this.fetchRepoPullRequests( + const wantedKeys = new Set(); + for (const workspace of projectWorkspaces) { + const key = upstreamKey( + workspace.upstreamOwner, + workspace.upstreamRepo, + workspace.upstreamBranch ?? workspace.branch, + ); + if (key) wantedKeys.add(key); + } + + const keyToPullRequest = await this.fetchRepoPullRequests( projectId, repo, - branchNames, + wantedKeys, ); for (const workspace of projectWorkspaces) { - const match = branchToPullRequest.get(workspace.branch) ?? null; + const key = upstreamKey( + workspace.upstreamOwner, + workspace.upstreamRepo, + workspace.upstreamBranch ?? workspace.branch, + ); + const match = key ? keyToPullRequest.get(key) : undefined; this.db .update(workspaces) - .set({ - pullRequestId: match?.id ?? null, - }) + .set({ pullRequestId: match?.id ?? null }) .where(eq(workspaces.id, workspace.id)) .run(); } @@ -391,33 +504,39 @@ export class PullRequestRuntimeManager { private async fetchRepoPullRequests( projectId: string, repo: NormalizedRepoIdentity, - branches: string[], + wantedKeys: Set, ): Promise> { + if (wantedKeys.size === 0) return new Map(); + const octokit = await this.github(); const nodes = await fetchRepositoryPullRequests(octokit, { owner: repo.owner, name: repo.name, }); - const wantedBranches = new Set(branches); - const latestByBranch = new Map(); + const latestByKey = new Map(); for (const node of nodes) { - if (!wantedBranches.has(node.headRefName)) continue; - const existing = latestByBranch.get(node.headRefName); + const key = upstreamKey( + node.headRepositoryOwner?.login ?? null, + node.headRepository?.name ?? null, + node.headRefName, + ); + if (!key || !wantedKeys.has(key)) continue; + const existing = latestByKey.get(key); if ( !existing || new Date(node.updatedAt).getTime() > new Date(existing.updatedAt).getTime() ) { - latestByBranch.set(node.headRefName, node); + latestByKey.set(key, node); } } - const branchToRow = new Map(); + const keyToRow = new Map(); const now = Date.now(); - for (const [branch, node] of latestByBranch) { + for (const [key, node] of latestByKey) { const existing = this.db.query.pullRequests .findFirst({ where: and( @@ -470,9 +589,9 @@ export class PullRequestRuntimeManager { .run(); } - branchToRow.set(branch, { id: rowId }); + keyToRow.set(key, { id: rowId }); } - return branchToRow; + return keyToRow; } } diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts index 0073f238276..bf579dae8d4 100644 --- a/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts +++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts @@ -10,6 +10,9 @@ export const PULL_REQUESTS_QUERY = ` isDraft headRefName headRefOid + isCrossRepository + headRepositoryOwner { login } + headRepository { name } reviewDecision updatedAt statusCheckRollup { diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts index 1372e841a53..02201ae66e4 100644 --- a/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts +++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts @@ -34,6 +34,9 @@ export interface GraphQLPullRequestNode { isDraft: boolean; headRefName: string; headRefOid: string; + isCrossRepository: boolean; + headRepositoryOwner: { login: string } | null; + headRepository: { name: string } | null; reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; updatedAt: string; statusCheckRollup: { diff --git a/packages/host-service/test/pull-requests.test.ts b/packages/host-service/test/pull-requests.test.ts index 685eda3ed30..206fd050b44 100644 --- a/packages/host-service/test/pull-requests.test.ts +++ b/packages/host-service/test/pull-requests.test.ts @@ -64,6 +64,9 @@ describe("PullRequestRuntimeManager branch sync", () => { expect(setMock).toHaveBeenCalledWith({ branch: "feature/unborn", headSha: null, + upstreamOwner: null, + upstreamRepo: null, + upstreamBranch: null, }); expect(refreshProjectMock).toHaveBeenCalledWith("project-1", true); }); diff --git a/plans/20260421-pr-detection-matcher-fix.md b/plans/20260421-pr-detection-matcher-fix.md new file mode 100644 index 00000000000..ee6b582a10a --- /dev/null +++ b/plans/20260421-pr-detection-matcher-fix.md @@ -0,0 +1,52 @@ +# Fix PR → workspace matcher (cross-fork collision) + +Shipped in PR #3625. + +## The bug + +Two workspace → PR match sites keyed on branch name alone. Any cross-fork PR whose `headRefName` collided with a local branch got attached — e.g. PR #3261 (`quueli/superset-windows:main` → `superset-sh/superset:main`) showing on every local `main` workspace. + +## Where the symptom came from + +- **v1 (`pr-resolution.ts`, visible to the user)** — powers `usePRStatus` via `workspaces.getGitHubStatus`. `prMatchesLocalBranch` accepted any PR whose `headRefName` equaled the local branch name. Cross-fork not considered. **This is what attached PR #3261 in the user's sidebar.** +- **v2 / host-service (`pull-requests.ts`, latent)** — powers `pullRequests.getByWorkspaces` (`_dashboard` sidebar) + `git.getPullRequest` (v2 review tab). Same branch-name-only matcher. Wasn't firing in the reported environment because the host-service `workspaces` table was empty / pointing at deleted worktree paths, but the bug existed in code. + +## What we shipped + +### v1 (`apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts`) + +- `prMatchesLocalBranch`: when the local branch has no fork-owner prefix, reject any PR with `isCrossRepository === true`. One line, uses the already-fetched and already-typed field that the predicate just never consulted. +- Module header marks it **v1-only, dies with v1 UI sunset** — don't evolve, v2 already resolves PRs via host-service. + +### v2 / host-service (`packages/host-service/src/runtime/pull-requests/`) + +- **GraphQL query extended** with `isCrossRepository`, `headRepositoryOwner { login }`, `headRepository { name }`. +- **Schema migration `0003_workspace_upstream_ref`** (local SQLite, not Neon): adds `upstream_owner`, `upstream_repo`, `upstream_branch` columns to `workspaces`; replaces `workspaces_branch_idx` with composite `workspaces_upstream_ref_idx`. +- **`resolveWorkspaceUpstream`** populates those columns during `syncWorkspaceBranches`. Resolution modeled on `gh` CLI: + - `@{push}` happy path — single `git rev-parse --abbrev-ref @{push}` returns `remote/branch` respecting all config precedence. + - Fallback walks `branch..pushRemote` → `remote.pushDefault` → `branch..remote`, handling URL-valued configs in addition to remote names. + - Fallback requires explicit `branch..merge` — without it, a repo-wide `remote.pushDefault` would re-open the collision hole on untracked branches (coderabbit-flagged). + - `upstream_branch` is stored separately from local `branch` so `gh pr checkout` renames (`main` → `quueli-main`) still match the PR's `headRefName`. +- **Matcher** keys on `(upstreamOwner, upstreamRepo, upstreamBranch)` tuples, lowercased for owner/repo (GitHub is case-insensitive there) and preserving branch casing. + +## What we didn't do + +- **Consolidate v1 and v2 into one path** — deferred. v1 dies with the v1 UI sunset (see `project_v1_sunset` memory). No port needed. +- **`headRefOid` SHA-fallback in v2** — v1 has it (matches by HEAD commit when no tracking remote is set), v2 doesn't yet. Can be ported when v1 is actually deleted. + +## Review feedback addressed + +- **cubic-dev-ai** — case-sensitive owner/repo key. Fixed by lowercasing in `upstreamKey`. +- **coderabbitai** — untracked branch could resolve via `remote.pushDefault` alone and latch onto same-named PR. Fixed by requiring `branch..merge` in fallback. + +## Verification + +- PR #3261 (cross-fork): workspace upstream `superset-sh/superset#main` vs PR key `quueli/superset-windows#main` — no match in v2. v1 predicate returns false via `isCrossRepository` check. ✓ +- PR #3625 (this PR, same-repo): local branch `pr-3261-detection-in-v1-sidebar` tracking `origin/pr-3261-detection-in-v1-sidebar`. Tuples match in v2. v1 predicate returns true. ✓ +- Fresh untracked branch: upstream null, no match. ✓ +- `gh pr checkout` cross-fork review (local `quueli-main` tracking fork's `main`): `upstream_branch = main`, matches PR's `headRefName = main`. ✓ + +## Commit trail + +- `aff1763bc` — main fix (both paths + schema + migration) after clean squash +- `6811f7449` — coderabbit-flagged fallback gate