Keep fork PR badges visible in worktrees
This commit is contained in:
@@ -879,6 +879,53 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
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 () => {
|
||||
let nowMs = 0;
|
||||
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
|
||||
|
||||
@@ -270,7 +270,7 @@ interface WorkspaceGitTarget {
|
||||
debounceTimer: NodeJS.Timeout | null;
|
||||
selfHealTimer: NodeJS.Timeout | null;
|
||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||
githubPollHeadRef: string | null;
|
||||
githubPollKey: string | null;
|
||||
refreshState: WorkspaceGitRefreshState;
|
||||
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
||||
latestGitLoadedAtMs: number | null;
|
||||
@@ -317,6 +317,11 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
|
||||
inFlight: Promise<T> | null;
|
||||
}
|
||||
|
||||
interface WorkspaceGitHubPollTarget {
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
}
|
||||
|
||||
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
|
||||
return {
|
||||
watch,
|
||||
@@ -796,7 +801,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
debounceTimer: null,
|
||||
selfHealTimer: null,
|
||||
githubPollSubscription: null,
|
||||
githubPollHeadRef: null,
|
||||
githubPollKey: null,
|
||||
refreshState: { status: "idle" },
|
||||
latestGit: null,
|
||||
latestGitLoadedAtMs: null,
|
||||
@@ -1156,23 +1161,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const headRef = git.currentBranch;
|
||||
const pollTarget = this.resolveGitHubPollTarget(target);
|
||||
const remoteUrl = git.remoteUrl;
|
||||
const hasGitHubRemote =
|
||||
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
|
||||
target.cachedGitHubRemote?.remoteUrl === remoteUrl &&
|
||||
target.cachedGitHubRemote.identity !== null;
|
||||
if (!headRef || !hasGitHubRemote) {
|
||||
if (!pollTarget || remoteUrl === null || !hasGitHubRemote) {
|
||||
this.stopGitHubPollForTarget(target);
|
||||
return;
|
||||
}
|
||||
if (target.githubPollHeadRef === headRef && target.githubPollSubscription) {
|
||||
const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget);
|
||||
if (target.githubPollKey === pollKey && target.githubPollSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopGitHubPollForTarget(target);
|
||||
target.githubPollHeadRef = headRef;
|
||||
target.githubPollKey = pollKey;
|
||||
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
||||
cwd: target.cwd,
|
||||
headRef,
|
||||
headRef: pollTarget.headRef,
|
||||
...(pollTarget.headRepositoryOwner
|
||||
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
|
||||
: {}),
|
||||
onStatus: (status) => {
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||
return;
|
||||
@@ -1183,17 +1193,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
},
|
||||
onError: (error) => {
|
||||
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",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
target.githubPollSubscription?.unsubscribe();
|
||||
target.githubPollSubscription = null;
|
||||
target.githubPollHeadRef = null;
|
||||
target.githubPollKey = null;
|
||||
}
|
||||
|
||||
private addWorkingTreeWatcher(
|
||||
@@ -1669,7 +1702,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
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(
|
||||
@@ -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> {
|
||||
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type GitHubCommandRunner,
|
||||
type GitHubCommandRunnerOptions,
|
||||
type GitHubCurrentPullRequestStatus,
|
||||
type GitHubReadOptions,
|
||||
} from "./github-service.js";
|
||||
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -186,7 +185,7 @@ function githubStatusFacts(
|
||||
}
|
||||
|
||||
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
|
||||
const reads: GitHubReadOptions[] = [];
|
||||
const reads: Parameters<typeof service.getCurrentPullRequestStatus>[0][] = [];
|
||||
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
|
||||
service.getCurrentPullRequestStatus = vi.fn(async (options) => {
|
||||
reads.push(options);
|
||||
@@ -662,6 +661,41 @@ describe("GitHubService", () => {
|
||||
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 () => {
|
||||
let now = 0;
|
||||
const runner = createRunner([
|
||||
|
||||
@@ -642,6 +642,7 @@ export interface GitHubService {
|
||||
retainCurrentPullRequestStatusPoll?(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}): { unsubscribe: () => void };
|
||||
@@ -720,6 +721,7 @@ interface InFlightCacheEntry {
|
||||
interface GitHubPollTarget {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
retainCount: number;
|
||||
timer: NodeJS.Timeout | 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({
|
||||
cwd: target.cwd,
|
||||
method: "getCurrentPullRequestStatus",
|
||||
args: { headRef: target.headRef },
|
||||
args: {
|
||||
headRef: target.headRef,
|
||||
headRepositoryOwner: target.headRepositoryOwner,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updatePollTargetAfterSuccess(update: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
status: GitHubCurrentPullRequestStatus | null;
|
||||
notify: boolean;
|
||||
}): void {
|
||||
@@ -872,6 +882,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
await api.getCurrentPullRequestStatus({
|
||||
cwd: target.cwd,
|
||||
headRef: target.headRef,
|
||||
headRepositoryOwner: target.headRepositoryOwner,
|
||||
reason: "self-heal-github",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1027,6 +1038,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
updatePollTargetAfterSuccess({
|
||||
cwd: input.cwd,
|
||||
headRef: input.headRef,
|
||||
headRepositoryOwner: input.headRepositoryOwner,
|
||||
status,
|
||||
notify: input.reason === "self-heal-github",
|
||||
});
|
||||
@@ -1241,6 +1253,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
target = {
|
||||
cwd: input.cwd,
|
||||
headRef: input.headRef,
|
||||
headRepositoryOwner: input.headRepositoryOwner,
|
||||
retainCount: 0,
|
||||
timer: null,
|
||||
latestStatus: null,
|
||||
|
||||
Reference in New Issue
Block a user