Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@ export function createLocalIpcHandlers(
runScheduleNow: ({ id }) => options.scheduleService.runNow(id),
getScheduleRuns: ({ id }) => dbListScheduleRuns(id),
getPrWatch: ({ projectId, prNumber }) => options.prWatchService.get(projectId, prNumber),
checkPrWatch: ({ projectId, prNumber }) =>
options.prWatchService.requestCheck(projectId, prNumber),
upsertPrWatch: (watch) => options.prWatchService.upsert(watch),
deletePrWatch: ({ projectId, prNumber }) => options.prWatchService.delete(projectId, prNumber),
checkForUpdate: () => options.autoUpdater.checkForUpdate(),
Expand Down
53 changes: 53 additions & 0 deletions src/main/prWatch/PrWatchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,59 @@ describe("PrWatchService", () => {
expect(store.get(project.id, pr.number)).not.toBeNull();
});

it("checks immediately when pending checks settle", async () => {
let currentPr = { ...pr, checksStatus: "PENDING" };
let currentDetails: PrDetails = {
...details,
checks: [{ name: "Test", state: "IN_PROGRESS", conclusion: "" }],
};
const { service, mergePr } = setup(
withoutAgent(watch({ watchEnabled: false, autoMerge: true })),
{
getPrForBranch: async () => currentPr,
getPrDetails: async () => currentDetails,
},
);

await service.tick();
expect(mergePr).not.toHaveBeenCalled();

currentPr = { ...pr, checksStatus: "SUCCESS" };
currentDetails = {
...details,
checks: [{ name: "Test", state: "COMPLETED", conclusion: "SUCCESS" }],
};
service.requestCheck(project.id, pr.number);

await vi.waitFor(() => expect(mergePr).toHaveBeenCalledOnce());
});

it("queues a settled-status check that arrives during an in-flight check", async () => {
let resolveFirstPr!: (value: PrData) => void;
const getPrForBranch = vi
.fn<PrWatchServiceOptions["getPrForBranch"]>()
.mockImplementationOnce(
() =>
new Promise<PrData>((resolve) => {
resolveFirstPr = resolve;
}),
)
.mockResolvedValue(pr);
const { service, mergePr } = setup(
withoutAgent(watch({ watchEnabled: false, autoMerge: true })),
{ getPrForBranch },
);

const firstCheck = service.tick();
await vi.waitFor(() => expect(getPrForBranch).toHaveBeenCalledOnce());
service.requestCheck(project.id, pr.number);
resolveFirstPr({ ...pr, checksStatus: "PENDING" });
await firstCheck;

await vi.waitFor(() => expect(getPrForBranch).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(mergePr).toHaveBeenCalledOnce());
});

it("removes a watch after the PR closes", async () => {
const { service, store } = setup(watch(), {
getPrForBranch: async () => ({ ...pr, state: "closed" }),
Expand Down
23 changes: 21 additions & 2 deletions src/main/prWatch/PrWatchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class PrWatchService {
private timer: ReturnType<typeof setInterval> | null = null;
private disposed = false;
private readonly checking = new Set<string>();
private readonly recheckRequested = new Set<string>();

constructor(private readonly options: PrWatchServiceOptions) {}

Expand All @@ -69,6 +70,7 @@ export class PrWatchService {
this.disposed = true;
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.recheckRequested.clear();
}

get(projectId: string, prNumber: number): PrWatch | null {
Expand All @@ -92,14 +94,27 @@ export class PrWatchService {
lastError: null,
};
this.options.store.upsert(watch);
void this.checkWatch(watch);
this.requestCheck(watch.projectId, watch.prNumber);
return watch;
}

delete(projectId: string, prNumber: number): void {
this.recheckRequested.delete(watchKey({ projectId, prNumber }));
this.options.store.delete(projectId, prNumber);
}

requestCheck(projectId: string, prNumber: number): void {
if (this.disposed) return;
const watch = this.options.store.get(projectId, prNumber);
if (!watch) return;
const key = watchKey(watch);
if (this.checking.has(key)) {
this.recheckRequested.add(key);
return;
}
void this.checkWatch(watch);
}

async tick(): Promise<void> {
if (this.disposed) return;
await Promise.allSettled(this.options.store.list().map((watch) => this.checkWatch(watch)));
Expand All @@ -121,7 +136,7 @@ export class PrWatchService {
: null,
};
this.options.store.upsert(settled);
if (!settled.lastError) void this.checkWatch(settled);
if (!settled.lastError) this.requestCheck(settled.projectId, settled.prNumber);
}
}

Expand Down Expand Up @@ -214,6 +229,10 @@ export class PrWatchService {
this.saveError(snapshot, error);
} finally {
this.checking.delete(key);
if (this.recheckRequested.delete(key) && !this.disposed) {
const latest = this.options.store.get(snapshot.projectId, snapshot.prNumber);
if (latest) void this.checkWatch(latest);
}
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/main/remote/RemoteAccessServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4356,6 +4356,7 @@ describe("RemoteAccessServer", () => {
const remove = vi.fn<(projectId: string, prNumber: number) => void>(() => {
stored = null;
});
const requestCheck = vi.fn<(projectId: string, prNumber: number) => void>();
const server = new RemoteAccessServer({
appVersion: "1.0.0",
identity: { desktopId: "desktop-test", label: "Test Desktop" },
Expand All @@ -4364,6 +4365,7 @@ describe("RemoteAccessServer", () => {
callSupervisor: vi.fn<RemoteAccessServerOptions["callSupervisor"]>(async () => "" as never),
prWatches: {
get: () => stored,
requestCheck,
upsert,
delete: remove,
},
Expand Down Expand Up @@ -4422,6 +4424,14 @@ describe("RemoteAccessServer", () => {
});
expect(upsert).toHaveBeenCalledWith(input);

const checkResponse = await fetch(new URL("/api/pr-watches/check", info.httpBaseUrl), {
method: "POST",
headers: operateHeaders,
body: JSON.stringify({ projectId: "project-1", prNumber: 42 }),
});
expect(checkResponse.status).toBe(200);
expect(requestCheck).toHaveBeenCalledWith("project-1", 42);

const deleteResponse = await fetch(new URL("/api/pr-watches", info.httpBaseUrl), {
method: "DELETE",
headers: operateHeaders,
Expand Down
1 change: 1 addition & 0 deletions src/main/remote/RemoteAccessServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export interface RemoteAccessServerOptions {
/** Persistent PR automation owned by the host process. */
readonly prWatches?: {
get(projectId: string, prNumber: number): PrWatch | null;
requestCheck(projectId: string, prNumber: number): void;
upsert(input: PrWatchInput): PrWatch;
delete(projectId: string, prNumber: number): void;
};
Expand Down
7 changes: 7 additions & 0 deletions src/main/remote/server/httpRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,13 @@ export async function handleHttp(
});
return;
}
if (req.method === "POST" && url.pathname === "/api/pr-watches/check") {
ctx.security.requireBearer(req, ["session:operate"]);
const key = prWatchKeySchema.parse(await readJsonBody(req));
ctx.requirePrWatchesGateway().requestCheck(key.projectId, key.prNumber);
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/pr-watches") {
ctx.security.requireBearer(req, ["session:operate"]);
const input = prWatchInputSchema.parse(await readJsonBody(req));
Expand Down
5 changes: 4 additions & 1 deletion src/mobile/bridge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,11 @@ describe("remote bridge", () => {
lastError: null,
};
const getPrWatch = vi.fn<(input: PrWatchKey) => Promise<PrWatch | null>>(async () => watch);
const checkPrWatch = vi.fn<(input: PrWatchKey) => Promise<void>>(async () => undefined);
const upsertPrWatch = vi.fn<(input: PrWatchInput) => Promise<PrWatch>>(async () => watch);
const deletePrWatch = vi.fn<(input: PrWatchKey) => Promise<void>>(async () => undefined);
setRemoteBridgeClient(
{ getPrWatch, upsertPrWatch, deletePrWatch } as unknown as RemoteDesktopClient,
{ getPrWatch, checkPrWatch, upsertPrWatch, deletePrWatch } as unknown as RemoteDesktopClient,
"darwin",
);
installRemoteBridge();
Expand All @@ -111,9 +112,11 @@ describe("remote bridge", () => {
config: { model: "gpt-5.6-sol" },
};
await expect(window.poracode.getPrWatch(key)).resolves.toEqual(watch);
await expect(window.poracode.checkPrWatch(key)).resolves.toBeUndefined();
await expect(window.poracode.upsertPrWatch(input)).resolves.toEqual(watch);
await expect(window.poracode.deletePrWatch(key)).resolves.toBeUndefined();
expect(getPrWatch).toHaveBeenCalledWith(key);
expect(checkPrWatch).toHaveBeenCalledWith(key);
expect(upsertPrWatch).toHaveBeenCalledWith(input);
expect(deletePrWatch).toHaveBeenCalledWith(key);
});
Expand Down
1 change: 1 addition & 0 deletions src/mobile/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ const remoteBridge = {
deleteSchedule: ({ id }: { id: string }) => requireClient().deleteSchedule(id),
runScheduleNow: ({ id }: { id: string }) => requireClient().runScheduleNow(id),
getPrWatch: (input: PrWatchKey) => requireClient().getPrWatch(input),
checkPrWatch: (input: PrWatchKey) => requireClient().checkPrWatch(input),
upsertPrWatch: (input: PrWatchInput) => requireClient().upsertPrWatch(input),
deletePrWatch: (input: PrWatchKey) => requireClient().deletePrWatch(input),

Expand Down
8 changes: 8 additions & 0 deletions src/renderer/state/gitRefresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const ghGetPrDetailsMock =
prNumber: number;
}) => Promise<{ details: PrDetails }>
>();
const checkPrWatchMock =
vi.fn<(payload: { projectId: string; prNumber: number }) => Promise<void>>();

const location: ProjectLocation = { kind: "posix", path: "/repo" };
const wslLocation: ProjectLocation = {
Expand Down Expand Up @@ -133,6 +135,8 @@ describe("pending PR refresh", () => {
vi.useFakeTimers();
ghGetPrForBranchMock.mockReset();
ghGetPrDetailsMock.mockReset();
checkPrWatchMock.mockReset();
checkPrWatchMock.mockResolvedValue(undefined);
Object.defineProperty(window, "poracode", {
configurable: true,
value: {
Expand All @@ -142,6 +146,7 @@ describe("pending PR refresh", () => {
.mockResolvedValue(undefined),
ghGetPrForBranch: ghGetPrForBranchMock,
ghGetPrDetails: ghGetPrDetailsMock,
checkPrWatch: checkPrWatchMock,
},
});
useGitStore.setState({
Expand Down Expand Up @@ -274,13 +279,16 @@ describe("pending PR refresh", () => {
expect(ghGetPrDetailsMock).toHaveBeenCalledWith({ projectLocation: location, prNumber: 42 });
expect(useGitStore.getState().prData[prKey]?.checksStatus).toBe("SUCCESS");
expect(useGitStore.getState().prDetails["p1#42"]?.checks[0]?.conclusion).toBe("SUCCESS");
expect(checkPrWatchMock).toHaveBeenCalledOnce();
expect(checkPrWatchMock).toHaveBeenCalledWith({ projectId: "p1", prNumber: 42 });

ghGetPrForBranchMock.mockClear();
ghGetPrDetailsMock.mockClear();
await vi.advanceTimersByTimeAsync(PR_PENDING_REFRESH_INTERVAL_MS);

expect(ghGetPrForBranchMock).not.toHaveBeenCalled();
expect(ghGetPrDetailsMock).not.toHaveBeenCalled();
expect(checkPrWatchMock).toHaveBeenCalledOnce();
});

it("polls when the PR summary is stale failed but loaded check details are pending", async () => {
Expand Down
38 changes: 31 additions & 7 deletions src/renderer/state/gitRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,12 @@ function applyWorktreeStatusBatch(
type ActiveGitProject = { id: string; location: ProjectLocation };

interface PendingPrRefreshTarget {
projectId: string;
projectLocation: ProjectLocation;
prKey: string;
branch: string;
detailsCacheKey?: string;
prNumber?: number;
detailsCacheKey: string;
prNumber: number;
}

interface PendingPrRefreshEntry {
Expand Down Expand Up @@ -376,17 +377,18 @@ function buildPendingPrRefreshTargets(
function visitBranchPr(project: ActiveGitProject, prKey: string, branch: string) {
const pr = gitState.prData[prKey];
if (!pr) return;
const detailsCacheKey = pr.number ? `${project.id}#${pr.number}` : undefined;
const details = detailsCacheKey ? gitState.prDetails[detailsCacheKey] : undefined;
const detailsCacheKey = `${project.id}#${pr.number}`;
const details = gitState.prDetails[detailsCacheKey];
const detailsStatus = aggregatePrChecksStatus(details?.checks);
const checksStatus = combineChecksStatus(detailsStatus, pr.checksStatus);
if (pr.state !== "open" || checksStatus !== "PENDING") return;
targets.set(detailsCacheKey ?? prKey, {
targets.set(detailsCacheKey, {
projectId: project.id,
projectLocation: project.location,
prKey,
branch,
...(detailsCacheKey ? { detailsCacheKey } : {}),
...(pr.number ? { prNumber: pr.number } : {}),
detailsCacheKey,
prNumber: pr.number,
});
}

Expand All @@ -408,6 +410,22 @@ function buildPendingPrRefreshTargets(
return targets;
}

function didPendingPrSettle(target: PendingPrRefreshTarget): boolean {
const gitState = useGitStore.getState();
const pr = gitState.prData[target.prKey];
if (pr === null) return true;
if (!pr || pr.number !== target.prNumber) return false;
const detailsStatus = aggregatePrChecksStatus(gitState.prDetails[target.detailsCacheKey]?.checks);
const checksStatus = combineChecksStatus(detailsStatus, pr.checksStatus);
return checksStatus === "SUCCESS" || checksStatus === "FAILURE";
}

function requestSettledPrCheck(target: PendingPrRefreshTarget): void {
void readBridge()
.checkPrWatch({ projectId: target.projectId, prNumber: target.prNumber })
.catch(() => undefined);
}

/**
* Fetch a single PR's data (and its details, when a number + cache key are
* known) and write both into the git store. Shared by the background
Expand Down Expand Up @@ -552,6 +570,12 @@ export function syncPendingPrRefreshProjects(activeProjects: readonly ActiveGitP
if (!target) {
clearInterval(entry.intervalId);
pendingPrRefreshEntries.delete(key);
if (
activeProjects.some((project) => project.id === entry.target.projectId) &&
didPendingPrSettle(entry.target)
) {
requestSettledPrCheck(entry.target);
}
continue;
}
entry.target = target;
Expand Down
1 change: 1 addition & 0 deletions src/shared/ipc/procedureMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export const MAIN_LOCAL_PROCEDURE_NAMES = [
"runScheduleNow",
"getScheduleRuns",
"getPrWatch",
"checkPrWatch",
"upsertPrWatch",
"deletePrWatch",
] as const satisfies readonly IpcProcedureName[];
Expand Down
5 changes: 5 additions & 0 deletions src/shared/ipc/procedures/prWatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const prWatchProcedures = {
"main-local",
prWatchKeySchema,
),
checkPrWatch: definePayloadProcedure<PrWatchKey, void, "main-local">(
"checkPrWatch",
"main-local",
prWatchKeySchema,
),
upsertPrWatch: definePayloadProcedure<PrWatchInput, PrWatch, "main-local">(
"upsertPrWatch",
"main-local",
Expand Down
10 changes: 9 additions & 1 deletion src/shared/remote/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("RemoteDesktopClient", () => {
]);
});

it("reads, enables, and deletes PR automation through the remote API", async () => {
it("reads, checks, enables, and deletes PR automation through the remote API", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const watch = {
projectId: "project one",
Expand Down Expand Up @@ -186,6 +186,9 @@ describe("RemoteDesktopClient", () => {
await expect(
client.getPrWatch({ projectId: watch.projectId, prNumber: watch.prNumber }),
).resolves.toEqual(watch);
await expect(
client.checkPrWatch({ projectId: watch.projectId, prNumber: watch.prNumber }),
).resolves.toBeUndefined();
await expect(client.upsertPrWatch(input)).resolves.toEqual(watch);
await expect(
client.deletePrWatch({ projectId: watch.projectId, prNumber: watch.prNumber }),
Expand All @@ -197,6 +200,11 @@ describe("RemoteDesktopClient", () => {
method: "GET",
body: undefined,
},
{
url: "https://relay.example.test/s/server-1/api/pr-watches/check",
method: "POST",
body: { projectId: watch.projectId, prNumber: watch.prNumber },
},
{
url: "https://relay.example.test/s/server-1/api/pr-watches",
method: "POST",
Expand Down
4 changes: 4 additions & 0 deletions src/shared/remote/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,10 @@ export class RemoteDesktopClient {
return result.watch;
}

async checkPrWatch(input: PrWatchKey): Promise<void> {
await this.requestJson("/api/pr-watches/check", { method: "POST", body: input });
}

async upsertPrWatch(input: PrWatchInput): Promise<PrWatch> {
const result = parseResponse(
prWatchResponseSchema,
Expand Down