Keep fork PR badges visible in worktrees

This commit is contained in:
Mohamed Boudra
2026-06-02 15:38:27 +07:00
parent f1a20a9fe9
commit 07ace19b69
4 changed files with 151 additions and 15 deletions
@@ -879,6 +879,53 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github.dispose?.(); github.dispose?.();
}); });
test("GitHub self-heal polling uses the fork PR head branch instead of the owner-prefixed local branch", async () => {
const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() }));
const github = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll,
};
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "fork-owner/open-button-targets-active-file",
branchRemoteName: "paseo-pr-1285",
branchMergeRef: "refs/heads/open-button-targets-active-file",
trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file",
pullRequestLookupTarget: {
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
},
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "fork-owner/open-button-targets-active-file",
remoteUrl: "git@github.com:getpaseo/paseo.git",
}),
);
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
github,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledWith(
expect.objectContaining({
cwd: REPO_CWD,
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
}),
);
});
subscription.unsubscribe();
service.dispose();
});
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => { test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
let nowMs = 0; let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = []; const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
@@ -270,7 +270,7 @@ interface WorkspaceGitTarget {
debounceTimer: NodeJS.Timeout | null; debounceTimer: NodeJS.Timeout | null;
selfHealTimer: NodeJS.Timeout | null; selfHealTimer: NodeJS.Timeout | null;
githubPollSubscription: { unsubscribe: () => void } | null; githubPollSubscription: { unsubscribe: () => void } | null;
githubPollHeadRef: string | null; githubPollKey: string | null;
refreshState: WorkspaceGitRefreshState; refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null; latestGitLoadedAtMs: number | null;
@@ -317,6 +317,11 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
inFlight: Promise<T> | null; inFlight: Promise<T> | null;
} }
interface WorkspaceGitHubPollTarget {
headRef: string;
headRepositoryOwner?: string;
}
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
return { return {
watch, watch,
@@ -796,7 +801,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
debounceTimer: null, debounceTimer: null,
selfHealTimer: null, selfHealTimer: null,
githubPollSubscription: null, githubPollSubscription: null,
githubPollHeadRef: null, githubPollKey: null,
refreshState: { status: "idle" }, refreshState: { status: "idle" },
latestGit: null, latestGit: null,
latestGitLoadedAtMs: null, latestGitLoadedAtMs: null,
@@ -1156,23 +1161,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return; return;
} }
const headRef = git.currentBranch; const pollTarget = this.resolveGitHubPollTarget(target);
const remoteUrl = git.remoteUrl;
const hasGitHubRemote = const hasGitHubRemote =
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl && target.cachedGitHubRemote?.remoteUrl === remoteUrl &&
target.cachedGitHubRemote.identity !== null; target.cachedGitHubRemote.identity !== null;
if (!headRef || !hasGitHubRemote) { if (!pollTarget || remoteUrl === null || !hasGitHubRemote) {
this.stopGitHubPollForTarget(target); this.stopGitHubPollForTarget(target);
return; return;
} }
if (target.githubPollHeadRef === headRef && target.githubPollSubscription) { const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget);
if (target.githubPollKey === pollKey && target.githubPollSubscription) {
return; return;
} }
this.stopGitHubPollForTarget(target); this.stopGitHubPollForTarget(target);
target.githubPollHeadRef = headRef; target.githubPollKey = pollKey;
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({ target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
cwd: target.cwd, cwd: target.cwd,
headRef, headRef: pollTarget.headRef,
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
onStatus: (status) => { onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) { if (!this.isActiveObservedWorkspaceTarget(target)) {
return; return;
@@ -1183,17 +1193,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}, },
onError: (error) => { onError: (error) => {
this.logger.warn( this.logger.warn(
{ err: error, cwd: target.cwd, headRef, reason: "self-heal-github" }, {
err: error,
cwd: target.cwd,
headRef: pollTarget.headRef,
headRepositoryOwner: pollTarget.headRepositoryOwner,
reason: "self-heal-github",
},
"Failed to run GitHub self-heal refresh", "Failed to run GitHub self-heal refresh",
); );
}, },
}); });
} }
private resolveGitHubPollTarget(target: WorkspaceGitTarget): WorkspaceGitHubPollTarget | null {
const git = target.latestGit;
if (!git?.currentBranch) {
return null;
}
const lookupTarget =
target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch
? target.latestFacts.pullRequestLookupTarget
: null;
if (lookupTarget) {
return lookupTarget;
}
return { headRef: git.currentBranch };
}
private stopGitHubPollForTarget(target: WorkspaceGitTarget): void { private stopGitHubPollForTarget(target: WorkspaceGitTarget): void {
target.githubPollSubscription?.unsubscribe(); target.githubPollSubscription?.unsubscribe();
target.githubPollSubscription = null; target.githubPollSubscription = null;
target.githubPollHeadRef = null; target.githubPollKey = null;
} }
private addWorkingTreeWatcher( private addWorkingTreeWatcher(
@@ -1669,7 +1702,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return null; return null;
} }
return JSON.stringify([git.remoteUrl, git.currentBranch]); const pollTarget = this.resolveGitHubPollTarget(target);
if (!pollTarget) {
return null;
}
return buildWorkspaceGitHubPollKey(git.remoteUrl, pollTarget);
} }
private rememberGitHubSnapshot( private rememberGitHubSnapshot(
@@ -1976,6 +2014,10 @@ function buildGitHubSnapshotFromStatus(
}; };
} }
function buildWorkspaceGitHubPollKey(remoteUrl: string, target: WorkspaceGitHubPollTarget): string {
return JSON.stringify([remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
}
async function runGitFetch(cwd: string): Promise<void> { async function runGitFetch(cwd: string): Promise<void> {
await runGitCommand(["fetch", "origin", "--prune"], { await runGitCommand(["fetch", "origin", "--prune"], {
cwd, cwd,
@@ -13,7 +13,6 @@ import {
type GitHubCommandRunner, type GitHubCommandRunner,
type GitHubCommandRunnerOptions, type GitHubCommandRunnerOptions,
type GitHubCurrentPullRequestStatus, type GitHubCurrentPullRequestStatus,
type GitHubReadOptions,
} from "./github-service.js"; } from "./github-service.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages"; import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
@@ -186,7 +185,7 @@ function githubStatusFacts(
} }
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) { function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
const reads: GitHubReadOptions[] = []; const reads: Parameters<typeof service.getCurrentPullRequestStatus>[0][] = [];
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service); const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
service.getCurrentPullRequestStatus = vi.fn(async (options) => { service.getCurrentPullRequestStatus = vi.fn(async (options) => {
reads.push(options); reads.push(options);
@@ -662,6 +661,41 @@ describe("GitHubService", () => {
service.dispose?.(); service.dispose?.();
}); });
it("retained fork PR status polls keep the head repository owner", async () => {
const runner = createRunner([
currentPullRequestJson({
headRefName: "open-button-targets-active-file",
headRepositoryOwner: { login: "fork-owner" },
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
});
await vi.advanceTimersByTimeAsync(0);
expect(reads).toEqual([
expect.objectContaining({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
reason: "self-heal-github",
}),
]);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
subscription?.unsubscribe();
service.dispose?.();
});
it("polls PR status at slow cadence after stable checks", async () => { it("polls PR status at slow cadence after stable checks", async () => {
let now = 0; let now = 0;
const runner = createRunner([ const runner = createRunner([
+15 -2
View File
@@ -642,6 +642,7 @@ export interface GitHubService {
retainCurrentPullRequestStatusPoll?(options: { retainCurrentPullRequestStatusPoll?(options: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headRepositoryOwner?: string;
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void; onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void; onError?: (error: unknown) => void;
}): { unsubscribe: () => void }; }): { unsubscribe: () => void };
@@ -720,6 +721,7 @@ interface InFlightCacheEntry {
interface GitHubPollTarget { interface GitHubPollTarget {
cwd: string; cwd: string;
headRef: string; headRef: string;
headRepositoryOwner?: string;
retainCount: number; retainCount: number;
timer: NodeJS.Timeout | null; timer: NodeJS.Timeout | null;
latestStatus: GitHubCurrentPullRequestStatus | null; latestStatus: GitHubCurrentPullRequestStatus | null;
@@ -813,17 +815,25 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
} }
} }
function getPollTargetKey(target: { cwd: string; headRef: string }): string { function getPollTargetKey(target: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
}): string {
return buildCacheKey({ return buildCacheKey({
cwd: target.cwd, cwd: target.cwd,
method: "getCurrentPullRequestStatus", method: "getCurrentPullRequestStatus",
args: { headRef: target.headRef }, args: {
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
},
}); });
} }
function updatePollTargetAfterSuccess(update: { function updatePollTargetAfterSuccess(update: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headRepositoryOwner?: string;
status: GitHubCurrentPullRequestStatus | null; status: GitHubCurrentPullRequestStatus | null;
notify: boolean; notify: boolean;
}): void { }): void {
@@ -872,6 +882,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
await api.getCurrentPullRequestStatus({ await api.getCurrentPullRequestStatus({
cwd: target.cwd, cwd: target.cwd,
headRef: target.headRef, headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
reason: "self-heal-github", reason: "self-heal-github",
}); });
} catch (error) { } catch (error) {
@@ -1027,6 +1038,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
updatePollTargetAfterSuccess({ updatePollTargetAfterSuccess({
cwd: input.cwd, cwd: input.cwd,
headRef: input.headRef, headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
status, status,
notify: input.reason === "self-heal-github", notify: input.reason === "self-heal-github",
}); });
@@ -1241,6 +1253,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
target = { target = {
cwd: input.cwd, cwd: input.cwd,
headRef: input.headRef, headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
retainCount: 0, retainCount: 0,
timer: null, timer: null,
latestStatus: null, latestStatus: null,