Skip to content
Open
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
42 changes: 41 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import authManager from './lib/authentication.js';
import socketAuthenticator from './lib/socket-auth.js';
import refresh from './lib/refresh.js';
import pullManager from './lib/pull-manager.js';
import git from './lib/git-manager.js';
import git, { observeRateLimit } from './lib/git-manager.js';
import dbManager from './lib/db-manager.js';
import pullQueue from './lib/pull-queue.js';
import { createRefreshAll } from './lib/refresh-all.js';
import { createPacer } from './lib/pacer.js';
import mainController from './controllers/main.js';
import hooksController from './controllers/githubHooks.js';
import statsController from './controllers/stats.js';
Expand All @@ -24,6 +26,21 @@ import { dirname } from 'path';
const reqLogger = Debug('pulldasher:server:request');
const debug = Debug('pulldasher');

// "Refresh all" compares GitHub with the board, so it waits for the board to
// load. Its refetches wait on their own pacer, which watches the quota every
// response reports, webhook refreshes included, so a press yields to them.
let boardLoaded;
const refreshAllPacer = createPacer();
observeRateLimit(refreshAllPacer);
const refreshAll = createRefreshAll({
board: pullManager.getPulls,
repos: config.repos,
closedSince: pullManager.getOldestAllowedPullTimestamp,
pacer: refreshAllPacer,
ready: new Promise(resolve => (boardLoaded = resolve)),
onProgress: progress => pullManager.broadcast('refreshAllProgress', progress),
});

const app = express();
const httpServer = createServer(app);
const maxPostSize = 1024 * 1024;
Expand Down Expand Up @@ -92,13 +109,25 @@ dbManager
pullManager.updatePull(pull);
});
pullQueue.resume();
boardLoaded();
})
.then(function () {
debug('Refreshing all open pulls from the API');
refresh.openPulls();
})
.done();

// Webhooks get lost, and a lost `closed` left a PR open on the board until the
// next restart (pulldasher#501 repairs it only at startup), which inflates every
// backlog number. Once an hour, list each repo's open pulls and refresh just the
// ones the DB has wrong.
const RECONCILE_MS = 60 * 60 * 1000;
setInterval(function () {
refresh.reconcileOpenPulls().catch(function (err) {
console.error('Hourly open-pull repair failed: %s', (err && err.message) || err);
});
}, RECONCILE_MS);

//====================================================
// Socket.IO
const io = new Server(httpServer);
Expand All @@ -119,12 +148,23 @@ io.on('connection', function (socket) {
socket.user = user;
socket.emit('authenticated');
pullManager.addSocket(socket);
// a board that connects mid-press shows how far it got
const progress = refreshAll.progress();
if (progress && progress.state !== 'done') {
socket.emit('refreshAllProgress', progress);
}
} else {
socket.emit('unauthenticated');
socket.disconnect();
}
});

socket.on('refreshAll', function () {
if (socket.user) {
refreshAll.start();
}
});

socket.on('refresh', function (repo, number) {
refresh.pull(repo, number).catch(function (err) {
console.error(
Expand Down
6 changes: 2 additions & 4 deletions frontend-v2/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { buildReviewerPools, turnFor } from './model/rotation';
import { shipRelevance, shippedToast } from './model/shipped';
import { notificationStale } from './model/notificationRelevance';
import type { Toast } from './model/toast';
import { claimReview, isSnoozed, usePulldasher } from './store';
import { claimReview, isSnoozed, refreshAllText, usePulldasher } from './store';
import { primeScope, useScope } from './prefs';
import { useBoardHotkeys, useHeaderHeightVar } from './hooks';
import { ageRotDays, getSettings, type Settings as SettingsShape, useSettings } from './settings';
Expand Down Expand Up @@ -961,9 +961,7 @@ export function App() {
// Out of flow (anchored left of the cluster): transient text
// must not shove the bell/legend/cog sideways mid-aim.
<span className="absolute inset-y-0 right-full mr-2 hidden items-center bg-surface pl-2 text-xs whitespace-nowrap text-ink-3 tabular-nums sm:flex">
{refreshProgress.done === refreshProgress.total
? `refreshed ${refreshProgress.total}`
: `refreshing ${refreshProgress.done} of ${refreshProgress.total}`}
{refreshAllText(refreshProgress)}
</span>
)}
<span
Expand Down
66 changes: 56 additions & 10 deletions frontend-v2/src/backend/socket.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { io, type Socket } from 'socket.io-client';
import type { InitializePayload, PullData, TokenResponse } from '../../../shared/types';
import type {
InitializePayload,
PullData,
RefreshAllProgress,
TokenResponse,
} from '../../../shared/types';
import { isDummy, loadDummy, dummyUser } from './dummy';
import { readSessionStorage, writeSessionStorage } from '../storage';

Expand All @@ -25,6 +30,10 @@
* (re)auth), so the UI can say "sign in again" instead of "retrying". */
onAuthExpired: (handler: (expired: boolean) => void) => () => void;
refreshPull: (repo: string, number: number) => void;
/** Ask the server to bring the board to GitHub's state. Every board hears
* how far it got through onRefreshAll. */
refreshAll: () => void;
onRefreshAll: (handler: (progress: RefreshAllProgress) => void) => () => void;
/** Ask to review a pull. The server derives the login from the socket's
* own auth — no login argument here. A claim is a GitHub review request
* the reviewer made on themselves (see types.ts's review_requests), so it
Expand Down Expand Up @@ -94,7 +103,7 @@
t => {
// a valid /token proves the app session is alive again
onAuth?.(false);
socket!.emit('authenticate', t.socketToken);

Check warning on line 106 in frontend-v2/src/backend/socket.ts

View workflow job for this annotation

GitHub Actions / Build

Forbidden non-null assertion
},
(err: unknown) => {
// a dead session (redirect/401) is "sign in again", not "retrying";
Expand Down Expand Up @@ -167,6 +176,16 @@
refreshPull(repo, number) {
getSocket().emit('refresh', repo, number);
},
refreshAll() {
getSocket().emit('refreshAll');
},
onRefreshAll(handler) {
const s = getSocket();
s.on('refreshAllProgress', handler);
return () => {
s.off('refreshAllProgress', handler);
};
},
claimReview(repo, number) {
getSocket().emit('claimReview', repo, number);
},
Expand All @@ -179,7 +198,8 @@
function dummyBackend(): Backend {
let emit: ((payload: InitializePayload | PullData) => void) | null = null;
let loaded: InitializePayload | null = null;
let staggered = 0;
let onPress: ((progress: RefreshAllProgress) => void) | null = null;
let pressing = false;

/** Mutate one dummy pull in place and re-emit it through the normal
* pullChange path — the dummy board's stand-in for a server round trip
Expand Down Expand Up @@ -232,20 +252,46 @@
return () => undefined;
},
refreshPull(repo, number) {
// The dummy data is static, but the refresh-progress UI still needs
// arrivals to count — re-emit the same pull on a stagger so the
// "X of N" counter animates to completion like a real refresh would.
// The dummy data is static: re-emit the same pull, as a server would
// after refetching it.
const pull = loaded?.pulls.find(p => p.repo === repo && p.number === number);
if (!pull || !emit) return;
staggered += 1;
if (pull) setTimeout(() => emit?.(pull), 300);
},
refreshAll() {
// No GitHub behind the dummy board: play a press that finds 4 pulls
// to refetch, one failing, so every state of the status line shows.
if (pressing) return;
pressing = true;
const total = 4;
const report = (p: RefreshAllProgress) => onPress?.(p);
report({ state: 'checking', done: 0, total: 0, failed: 0, skipped: 0 });
for (let done = 0; done <= total; done++) {
setTimeout(
() =>
report({
state: 'refreshing',
done,
total,
failed: done === total ? 1 : 0,
skipped: 0,
}),
1200 + done * 500
);
}
setTimeout(
() => {
staggered -= 1;
emit?.(pull);
pressing = false;
report({ state: 'done', done: total, total, failed: 1, skipped: 0 });
},
300 + staggered * 15
1300 + total * 500
);
},
onRefreshAll(handler) {
onPress = handler;
return () => {
onPress = null;
};
},
claimReview(repo, number) {
updatePull(repo, number, pull => ({
...pull,
Expand Down
27 changes: 9 additions & 18 deletions frontend-v2/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Settings as SettingsIcon, Trash2, X } from 'lucide-react';
import { clearStoredPrefs } from '../storage';
import { refreshAll, usePulldasher } from '../store';
import { refreshAll, refreshAllText, usePulldasher } from '../store';
import {
addCodeRegion,
removeCodeRegion,
Expand Down Expand Up @@ -119,7 +119,6 @@ export function Settings({
const { refreshProgress } = usePulldasher();
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [refreshNote, setRefreshNote] = useState('');
const { armed: armReset, run: runReset } = useArmedConfirm();

// open on demand — the code-regions tip's "Set them up" action dispatches
Expand Down Expand Up @@ -414,33 +413,25 @@ export function Settings({
<section className="border-t border-secondary px-4 py-3.5">
<Explainer summary="Advanced">
<span className="block text-ink-2">
Re-fetch every open PR from GitHub now, instead of waiting for the
next webhook. The board updates as each one comes back.
Check the board against GitHub and re-fetch every PR it has wrong,
including ones it missed or still shows open. The board updates as
each one comes back.
</span>
<div className="flex items-center gap-3 pt-1">
<QuietButton
size="md"
disabled={!!refreshProgress}
onClick={() => {
// live progress (store.refreshProgress) takes over
// from here; the local note only covers the no-op
if (refreshAll() === 0) {
setRefreshNote('nothing to refresh');
setTimeout(() => setRefreshNote(''), 2500);
}
}}
// one press at a time, for every board: the server
// ignores a second while one runs
disabled={!!refreshProgress && refreshProgress.state !== 'done'}
onClick={() => refreshAll()}
>
Refresh all
</QuietButton>
{/* role=status stays mounted so the announcement fires
when the text lands — a screen reader hears the
confirmation, not just sighted users */}
<span role="status" className="text-xs text-ink-3 tabular-nums">
{refreshProgress
? refreshProgress.done === refreshProgress.total
? `refreshed ${refreshProgress.total}`
: `refreshing ${refreshProgress.done} of ${refreshProgress.total}…`
: refreshNote}
{refreshProgress ? refreshAllText(refreshProgress) : ''}
</span>
</div>

Expand Down
24 changes: 23 additions & 1 deletion frontend-v2/src/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { pullKey } from '../../shared/format';
import type { PullData } from '../../shared/types';
import { isSnoozed, type SnoozeRecord } from './store';
import { isSnoozed, refreshAllText, type SnoozeRecord } from './store';

// isSnoozed reads only repo/number/updated_at and a few status counts, so a
// minimal pull is enough; the outer cast keeps the fixture to what it touches.
Expand Down Expand Up @@ -145,3 +145,25 @@ describe('isSnoozed', () => {
).toBe(false);
});
});

describe('refreshAllText', () => {
const done = (total: number, failed = 0, skipped = 0) =>
refreshAllText({ state: 'done', done: total, total, failed, skipped });

it('says how far a press got while it runs', () => {
expect(refreshAllText({ state: 'checking', done: 0, total: 0, failed: 0, skipped: 0 })).toBe(
'checking GitHub…'
);
expect(
refreshAllText({ state: 'refreshing', done: 2, total: 5, failed: 0, skipped: 0 })
).toBe('refreshing 2 of 5…');
});

it('says what a finished press did, failures included', () => {
expect(done(0)).toBe('up to date');
expect(done(5)).toBe('refreshed 5');
expect(done(5, 1)).toBe('refreshed 4 · 1 failed');
expect(done(1, 1)).toBe('1 failed');
expect(done(0, 0, 2)).toBe('up to date · couldn’t read 2 repos');
});
});
Loading
Loading