Skip to content

Commit eb60531

Browse files
committed
fix(pr-checks): tighten post-push refresh and branch detection
- Gate the post-push refresh suppression on a new hasFetched flag so the first fetch after a brand-new watcher is never swallowed; the old lastRefreshedAt==0 heuristic broke as soon as refreshPrChecksWatcher bumped the timestamp. - Stop requiring the local branch head to match the PR head when detecting a branch PR (failed whenever the local branch was ahead). Pre-check that the worktree directory still exists before spawning gh, and only flip the gh-missing kill switch when the ENOENT actually points at gh — a vanished cwd no longer disables detection. - Clear an auto-detected prUrl when the task branch is renamed so the next scan repopulates from the new branch. - Renderer: extract a startWatcher helper used by both the createEffect pass and post-detection path, and add a disposed flag so async results don't mutate state after cleanup. - TaskBranchInfoBar: label the Source and PR buttons, and keep the PR status indicator in a fixed-size slot so the row doesn't jitter as state moves between none/pending/settled.
1 parent 4aaf7db commit eb60531

9 files changed

Lines changed: 471 additions & 70 deletions

File tree

electron/ipc/pr-checks.test.ts

Lines changed: 161 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { promisify } from 'util';
33

4+
const { mockStat } = vi.hoisted(() => ({
5+
mockStat: vi.fn(),
6+
}));
7+
48
vi.mock('child_process', () => {
59
const mockExecFile = vi.fn();
610
(mockExecFile as unknown as Record<symbol, unknown>)[promisify.custom] = (
@@ -17,6 +21,10 @@ vi.mock('child_process', () => {
1721
return { execFile: mockExecFile };
1822
});
1923

24+
vi.mock('fs/promises', () => ({
25+
stat: mockStat,
26+
}));
27+
2028
vi.mock('electron', () => ({
2129
Notification: class {
2230
static isSupported(): boolean {
@@ -37,6 +45,9 @@ import {
3745
detectPrUrlForBranch,
3846
__resetForTests,
3947
__getStateForTests,
48+
__runTickForTests,
49+
initPrChecks,
50+
refreshPrChecksWatcher,
4051
startPrChecksWatcher,
4152
type PrCheckRun,
4253
} from './pr-checks.js';
@@ -59,6 +70,23 @@ const run = (name: string, bucket: PrCheckRun['bucket']): PrCheckRun => ({
5970
bucket,
6071
});
6172

73+
const flushPromises = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
74+
75+
function fakeWindow(send: ReturnType<typeof vi.fn>): Parameters<typeof initPrChecks>[0] {
76+
return {
77+
on: vi.fn(),
78+
isDestroyed: () => false,
79+
isVisible: () => false,
80+
webContents: { send },
81+
show: vi.fn(),
82+
focus: vi.fn(),
83+
} as unknown as Parameters<typeof initPrChecks>[0];
84+
}
85+
86+
beforeEach(() => {
87+
mockStat.mockResolvedValue({ isDirectory: () => true });
88+
});
89+
6290
describe('summarize', () => {
6391
it('empty list is none with zero counts', () => {
6492
expect(summarize([])).toEqual({ overall: 'none', passing: 0, pending: 0, failing: 0 });
@@ -206,71 +234,178 @@ describe('detectPrUrlForBranch', () => {
206234
});
207235

208236
it('finds an open PR for a branch', async () => {
209-
const calls = stubGh((_args, cb, cmd) => {
210-
if (cmd === 'git') {
211-
cb(null, 'abc123\n', '');
212-
return;
213-
}
237+
const calls = stubGh((_args, cb) => {
214238
cb(
215239
null,
216240
JSON.stringify([
217241
{
218242
url: 'https://github.com/a/b/pull/11',
219243
headRefName: 'task/my-branch',
220-
headRefOid: 'old',
221-
},
222-
{
223-
url: 'https://github.com/a/b/pull/12',
224-
headRefName: 'task/my-branch',
225-
headRefOid: 'abc123',
226244
},
227245
]),
228246
'',
229247
);
230248
});
231249
await expect(detectPrUrlForBranch('/repo/worktree', 'task/my-branch')).resolves.toBe(
232-
'https://github.com/a/b/pull/12',
250+
'https://github.com/a/b/pull/11',
233251
);
234-
expect(calls[0]).toEqual(['rev-parse', 'task/my-branch']);
235-
expect(calls[1]).toEqual([
252+
expect(calls).toHaveLength(1);
253+
expect(calls[0]).toEqual([
236254
'pr',
237255
'list',
238256
'--state',
239257
'open',
240258
'--head',
241259
'task/my-branch',
242260
'--json',
243-
'url,headRefName,headRefOid',
261+
'url,headRefName',
244262
'--limit',
245263
'20',
246264
]);
247265
});
248266

267+
it('does not require the local branch head to match the PR head', async () => {
268+
stubGh((_args, cb) =>
269+
cb(
270+
null,
271+
JSON.stringify([
272+
{
273+
url: 'https://github.com/a/b/pull/12',
274+
headRefName: 'task/ahead-locally',
275+
headRefOid: 'remote-sha',
276+
},
277+
]),
278+
'',
279+
),
280+
);
281+
await expect(detectPrUrlForBranch('/repo/worktree', 'task/ahead-locally')).resolves.toBe(
282+
'https://github.com/a/b/pull/12',
283+
);
284+
});
285+
249286
it('returns null when the branch has no open PR', async () => {
250-
stubGh((_args, cb, cmd) => cb(null, cmd === 'git' ? 'abc123\n' : JSON.stringify([]), ''));
287+
stubGh((_args, cb) => cb(null, JSON.stringify([]), ''));
251288
await expect(detectPrUrlForBranch('/repo/worktree', 'task/no-pr')).resolves.toBe(null);
252289
});
253290

291+
it('returns null without invoking gh when the worktree path is gone', async () => {
292+
const err = new Error('missing worktree') as NodeJS.ErrnoException;
293+
err.code = 'ENOENT';
294+
mockStat.mockRejectedValueOnce(err);
295+
const calls = stubGh((_args, cb) => cb(null, JSON.stringify([]), ''));
296+
297+
await expect(detectPrUrlForBranch('/repo/missing-worktree', 'task/no-pr')).resolves.toBe(null);
298+
299+
expect(calls).toHaveLength(0);
300+
});
301+
254302
it('rejects malformed PR URLs from gh output', async () => {
255-
stubGh((_args, cb, cmd) =>
303+
stubGh((_args, cb) =>
256304
cb(
257305
null,
258-
cmd === 'git'
259-
? 'abc123\n'
260-
: JSON.stringify([
261-
{
262-
url: 'https://github.com/a/b/issues/12',
263-
headRefName: 'task/issue',
264-
headRefOid: 'abc123',
265-
},
266-
]),
306+
JSON.stringify([
307+
{
308+
url: 'https://github.com/a/b/issues/12',
309+
headRefName: 'task/issue',
310+
},
311+
]),
267312
'',
268313
),
269314
);
270315
await expect(detectPrUrlForBranch('/repo/worktree', 'task/issue')).resolves.toBe(null);
271316
});
272317
});
273318

319+
describe('refreshPrChecksWatcher', () => {
320+
beforeEach(() => {
321+
vi.clearAllMocks();
322+
__resetForTests();
323+
});
324+
325+
it('does not suppress the first fetched status after a post-push refresh', async () => {
326+
const send = vi.fn();
327+
initPrChecks(fakeWindow(send));
328+
stubGh((_args, cb) =>
329+
cb(
330+
null,
331+
JSON.stringify({
332+
state: 'OPEN',
333+
headRefOid: 'new-sha',
334+
statusCheckRollup: [{ name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }],
335+
}),
336+
'',
337+
),
338+
);
339+
340+
startPrChecksWatcher({
341+
taskId: 't1',
342+
prUrl: 'https://github.com/a/b/pull/1',
343+
taskName: 'test',
344+
});
345+
refreshPrChecksWatcher('t1');
346+
await flushPromises();
347+
348+
expect(send).toHaveBeenCalledTimes(2);
349+
expect(send.mock.calls[0][1]).toMatchObject({ taskId: 't1', overall: 'pending' });
350+
expect(send.mock.calls[1][1]).toMatchObject({
351+
taskId: 't1',
352+
overall: 'success',
353+
passing: 1,
354+
});
355+
});
356+
357+
it('keeps stale post-push status hidden until GitHub reports a new head', async () => {
358+
const send = vi.fn();
359+
initPrChecks(fakeWindow(send));
360+
const statuses = [
361+
{
362+
state: 'OPEN',
363+
headRefOid: 'old-sha',
364+
statusCheckRollup: [{ name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }],
365+
},
366+
{
367+
state: 'OPEN',
368+
headRefOid: 'old-sha',
369+
statusCheckRollup: [{ name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }],
370+
},
371+
{
372+
state: 'OPEN',
373+
headRefOid: 'new-sha',
374+
statusCheckRollup: [{ name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }],
375+
},
376+
];
377+
let nextStatus = 0;
378+
stubGh((_args, cb) => {
379+
const payload = statuses[Math.min(nextStatus, statuses.length - 1)];
380+
nextStatus += 1;
381+
cb(null, JSON.stringify(payload), '');
382+
});
383+
384+
startPrChecksWatcher({
385+
taskId: 't1',
386+
prUrl: 'https://github.com/a/b/pull/1',
387+
taskName: 'test',
388+
});
389+
await flushPromises();
390+
expect(send).toHaveBeenCalledTimes(1);
391+
392+
refreshPrChecksWatcher('t1');
393+
expect(send).toHaveBeenCalledTimes(2);
394+
expect(send.mock.calls[1][1]).toMatchObject({ taskId: 't1', overall: 'pending' });
395+
396+
await __runTickForTests();
397+
expect(send).toHaveBeenCalledTimes(2);
398+
399+
await __runTickForTests();
400+
expect(send).toHaveBeenCalledTimes(3);
401+
expect(send.mock.calls[2][1]).toMatchObject({
402+
taskId: 't1',
403+
overall: 'success',
404+
passing: 1,
405+
});
406+
});
407+
});
408+
274409
describe('startPrChecksWatcher — graceful degradation', () => {
275410
beforeEach(() => {
276411
vi.clearAllMocks();

electron/ipc/pr-checks.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFile } from 'child_process';
22
import { promisify } from 'util';
33
import { Notification, type BrowserWindow } from 'electron';
4+
import { stat } from 'fs/promises';
45
import { IPC } from './channels.js';
56

67
const exec = promisify(execFile);
@@ -40,6 +41,7 @@ interface TaskEntry {
4041
failing: number;
4142
checks: PrCheckRun[];
4243
headRefOid: string | null;
44+
hasFetched: boolean;
4345
lastRefreshedAt: number;
4446
/** SHA at which we last fired a settled notification. null if never notified. */
4547
lastNotifiedSha: string | null;
@@ -114,6 +116,7 @@ export function startPrChecksWatcher(args: {
114116
failing: 0,
115117
checks: [],
116118
headRefOid: null,
119+
hasFetched: false,
117120
lastRefreshedAt: 0,
118121
lastNotifiedSha: null,
119122
lastNotifiedOutcome: null,
@@ -136,6 +139,10 @@ export function refreshPrChecksWatcher(taskId: string): void {
136139
if (disabled) return;
137140
const entry = tasks.get(taskId);
138141
if (!entry) return;
142+
// Deliberately leave entry.headRefOid in place: refreshOne uses it as the
143+
// pre-push SHA to detect when GitHub has caught up. Clearing it here would
144+
// make every fetch within the grace window look like a SHA change and let
145+
// stale old-head check data through.
139146
entry.postPushRefreshUntil = Date.now() + POST_PUSH_REFRESH_GRACE_MS;
140147
entry.overall = 'pending';
141148
entry.passing = 0;
@@ -226,10 +233,13 @@ async function refreshOne(taskId: string): Promise<void> {
226233
const { overall, passing, pending, failing } = summarize(checks);
227234
const counts = { passing, pending, failing };
228235
const prevSha = entry.headRefOid;
229-
const shaChanged = prevSha !== null && prevSha !== view.headRefOid;
230-
const firstRefresh = entry.lastRefreshedAt === 0;
236+
const shaChanged = entry.hasFetched && prevSha !== view.headRefOid;
237+
const firstRefresh = !entry.hasFetched;
231238
const waitingForPostPushHead =
232-
entry.postPushRefreshUntil !== null && !shaChanged && Date.now() < entry.postPushRefreshUntil;
239+
entry.postPushRefreshUntil !== null &&
240+
entry.hasFetched &&
241+
!shaChanged &&
242+
Date.now() < entry.postPushRefreshUntil;
233243
if (waitingForPostPushHead) {
234244
entry.lastRefreshedAt = Date.now();
235245
return;
@@ -257,6 +267,7 @@ async function refreshOne(taskId: string): Promise<void> {
257267
entry.failing = counts.failing;
258268
entry.checks = checks;
259269
entry.headRefOid = view.headRefOid;
270+
entry.hasFetched = true;
260271
entry.lastRefreshedAt = Date.now();
261272

262273
if (nothingChanged) return;
@@ -384,12 +395,7 @@ export async function detectPrUrlForBranch(
384395
worktreePath: string,
385396
branchName: string,
386397
): Promise<string | null> {
387-
const { stdout: headStdout } = await exec('git', ['rev-parse', branchName], {
388-
cwd: worktreePath,
389-
timeout: GH_TIMEOUT_MS,
390-
maxBuffer: GH_MAX_BUFFER,
391-
});
392-
const localHead = headStdout.trim();
398+
if (!(await isDirectory(worktreePath))) return null;
393399
const { stdout } = await exec(
394400
'gh',
395401
[
@@ -400,7 +406,7 @@ export async function detectPrUrlForBranch(
400406
'--head',
401407
branchName,
402408
'--json',
403-
'url,headRefName,headRefOid',
409+
'url,headRefName',
404410
'--limit',
405411
'20',
406412
],
@@ -411,13 +417,21 @@ export async function detectPrUrlForBranch(
411417
const match = parsed.find((item) => {
412418
if (!item || typeof item !== 'object') return false;
413419
const pr = item as Record<string, unknown>;
414-
return pr['headRefName'] === branchName && pr['headRefOid'] === localHead;
420+
return pr['headRefName'] === branchName;
415421
});
416422
if (!match || typeof match !== 'object') return null;
417423
const url = (match as Record<string, unknown>)['url'];
418424
return typeof url === 'string' && isPrUrl(url) ? url : null;
419425
}
420426

427+
async function isDirectory(path: string): Promise<boolean> {
428+
try {
429+
return (await stat(path)).isDirectory();
430+
} catch {
431+
return false;
432+
}
433+
}
434+
421435
/** Single gh call: combines PR state, head SHA, and check runs in one fork.
422436
* Uses `statusCheckRollup` which bundles check-run data with a normalised
423437
* conclusion, so we map that to the same `bucket` taxonomy `gh pr checks`
@@ -534,3 +548,7 @@ export function __getStateForTests(): {
534548
} {
535549
return { disabled, disabledReason, taskIds: Array.from(tasks.keys()) };
536550
}
551+
552+
export function __runTickForTests(): Promise<void> {
553+
return runTick();
554+
}

0 commit comments

Comments
 (0)