Keep fork PR badges visible in worktrees
This commit is contained in:
@@ -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([
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user