- 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.
{
- // 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
@@ -436,11 +431,7 @@ export function Settings({
when the text lands — a screen reader hears the
confirmation, not just sighted users */}
- {refreshProgress
- ? refreshProgress.done === refreshProgress.total
- ? `refreshed ${refreshProgress.total}`
- : `refreshing ${refreshProgress.done} of ${refreshProgress.total}…`
- : refreshNote}
+ {refreshProgress ? refreshAllText(refreshProgress) : ''}
diff --git a/frontend-v2/src/store.test.ts b/frontend-v2/src/store.test.ts
index 20b6c1a3..d5dad8a6 100644
--- a/frontend-v2/src/store.test.ts
+++ b/frontend-v2/src/store.test.ts
@@ -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.
@@ -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');
+ });
+});
diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts
index 57831b53..b838b544 100644
--- a/frontend-v2/src/store.ts
+++ b/frontend-v2/src/store.ts
@@ -10,12 +10,12 @@ import { isBotLogin } from '../../shared/model/visibility';
import { getSettings, subscribeSettings } from './settings';
import { epoch, pullKey } from '../../shared/format';
import { readStorage, writeStorage } from './storage';
-import type { PullData, RepoSpec } from '../../shared/types';
+import type { PullData, RefreshAllProgress, RepoSpec } from '../../shared/types';
/**
* The one store: raw pulls keyed by repo#number, re-derived and re-published
* as a sorted snapshot whenever the socket delivers. Throttled so a burst of
- * pullChange events (bulk refresh on the server) renders once, not N times.
+ * pullChange events (a "Refresh all" on the server) renders once, not N times.
*/
export interface Snapshot {
@@ -38,9 +38,9 @@ export interface Snapshot {
* review baseline that wakes it): hidden for a day or until it changes.
* Persisted per-browser. */
snoozed: Readonly>;
- /** "Refresh all" in progress (or just finished): how many of the pulls
- * queued at kickoff have reported back. Null when no refresh is running. */
- refreshProgress: { done: number; total: number } | null;
+ /** "Refresh all" running on the server, or finished a moment ago. Null
+ * otherwise. */
+ refreshProgress: RefreshAllProgress | null;
}
const LAST_SEEN_KEY = 'pd2.lastSeen';
@@ -59,34 +59,20 @@ let authFailed = false;
let lastPayloadAt = 0;
const listeners = new Set<() => void>();
-// "Refresh all" progress: the set of pull keys still awaiting a pullChange
-// since the last refreshAll() kickoff. total is fixed at kickoff so the
-// header/Settings chip reads "N of TOTAL" even as pending shrinks. Null
-// means no refresh is in flight (or its grace window has elapsed).
-let refreshTracking: { pending: Set; total: number } | null = null;
-let refreshCompleteTimer: ReturnType | null = null;
+// "Refresh all" as the server last reported it. A finished press stays on
+// screen a few seconds, or a fast one would flash and clear before anyone
+// reads it.
+let refreshProgress: RefreshAllProgress | null = null;
+let refreshClearTimer: ReturnType | null = null;
-/** A reconnect resends everything as one 'initialize' payload, which isn't
- * the per-pull pullChange this tracking is counting — treat it as "the
- * refresh's work is moot", not "N more arrived", or the counter would stall
- * short of total forever. */
-function clearRefreshTracking() {
- if (refreshCompleteTimer != null) clearTimeout(refreshCompleteTimer);
- refreshCompleteTimer = null;
- refreshTracking = null;
-}
-
-/** Count a pullChange toward the in-flight refresh, if one is running. On
- * the last arrival, hold the finished "N of N" on screen briefly — a fast
- * board would otherwise flash the count and clear it before anyone reads it. */
-function noteRefreshArrival(key: string) {
- if (!refreshTracking || !refreshTracking.pending.delete(key)) return;
- if (refreshTracking.pending.size > 0) return;
- refreshCompleteTimer = setTimeout(() => {
- refreshTracking = null;
- refreshCompleteTimer = null;
- schedulePublish();
- }, 4000);
+function setRefreshProgress(progress: RefreshAllProgress | null) {
+ if (refreshClearTimer != null) clearTimeout(refreshClearTimer);
+ refreshClearTimer = null;
+ refreshProgress = progress;
+ if (progress?.state === 'done') {
+ refreshClearTimer = setTimeout(() => setRefreshProgress(null), 6000);
+ }
+ schedulePublish();
}
// The marker moves ONLY by the user's hand — the "Recently updated" lane's
@@ -278,12 +264,7 @@ function publish() {
lastPayloadAt,
lastSeen,
snoozed: { ...snoozed },
- refreshProgress: refreshTracking
- ? {
- done: refreshTracking.total - refreshTracking.pending.size,
- total: refreshTracking.total,
- }
- : null,
+ refreshProgress,
};
for (const fn of listeners) fn();
}
@@ -324,17 +305,16 @@ function start() {
extraBots = new Set(payload.bots ?? []);
for (const p of payload.pulls) raw.set(pullKey(p), p);
initialized = true;
- // a reconnect resends everything; counting it as refresh progress
- // would either double-count or strand the tracker short of total
- clearRefreshTracking();
+ // a press that ended while this board was away mustn't stay stuck
+ // on screen; the server sends one still running right after this
+ setRefreshProgress(null);
} else {
- const key = pullKey(payload);
- raw.set(key, payload);
- noteRefreshArrival(key);
+ raw.set(pullKey(payload), payload);
}
lastPayloadAt = Date.now() / 1000;
schedulePublish();
});
+ backend.onRefreshAll(setRefreshProgress);
backend.onConnection(state => {
connection = state;
schedulePublish();
@@ -377,23 +357,20 @@ export function releaseReview(pull: Pick): void {
}
/**
- * Settings action: ask the server to re-fetch every open pull from GitHub.
- * The socket protocol has no bulk refresh, so this fans out one per-pull
- * refresh (the same event a row's refresh button sends) and lets the server's
- * serial refresh queue work through them. Returns how many were queued so the
- * UI can say so. Closed pulls are historical, so they're left out.
+ * Settings action: ask the server to bring the board to GitHub's state. It
+ * compares GitHub with the board and refetches only what differs; every open
+ * board hears its progress, so this returns nothing.
*/
-export function refreshAll(): number {
- const opens = [...raw.values()].filter(p => p.state === 'open');
- if (opens.length === 0) return 0;
- // a re-click mid-refresh restarts the count from this batch, not a merge
- // with the last one's leftovers
- clearRefreshTracking();
- refreshTracking = {
- pending: new Set(opens.map(pullKey)),
- total: opens.length,
- };
- for (const p of opens) backend.refreshPull(p.repo, p.number);
- schedulePublish();
- return opens.length;
+export const refreshAll = backend.refreshAll;
+
+/** One wording for a press, wherever it shows (the header, Settings). */
+export function refreshAllText(p: RefreshAllProgress): string {
+ if (p.state === 'checking') return 'checking GitHub…';
+ if (p.state === 'refreshing') return `refreshing ${p.done} of ${p.total}…`;
+ const refreshed = p.done - p.failed;
+ const parts = refreshed > 0 || p.failed > 0 ? [] : ['up to date'];
+ if (refreshed > 0) parts.push(`refreshed ${refreshed}`);
+ if (p.failed > 0) parts.push(`${p.failed} failed`);
+ if (p.skipped > 0) parts.push(`couldn’t read ${p.skipped} repo${p.skipped === 1 ? '' : 's'}`);
+ return parts.join(' · ');
}
diff --git a/lib/git-manager.js b/lib/git-manager.js
index 6e482006..8b83a54e 100644
--- a/lib/git-manager.js
+++ b/lib/git-manager.js
@@ -73,13 +73,13 @@ github.hook.error('request', (error, options) => {
* Feed a pacer the quota headers from every GitHub response, so it can pace bulk
* work against real consumption — crucially the per-pull/issue fan-out deep in
* `parse`/`parseIssue`, which is the bulk of a backfill's spend and which the
- * caller can't see to observe itself. Only the CLI backfill bins call this (once
- * at startup, with their pacer); the server never installs an observer, so its
- * webhook/socket responses aren't tracked and nothing is paced.
+ * caller can't see to observe itself. The CLI backfill bins call this once at
+ * startup with their pacer, and the server with the pacer "Refresh all" waits
+ * on; the webhook/socket responses it observes are never paced themselves.
*
* Installs the hooks at most once per process: they close over the first
* pacer, and a second call would only stack duplicate observers on the shared
- * client (double-counting every response). The bins call this exactly once.
+ * client (double-counting every response). Each entry point calls this once.
*/
let rateLimitObserved = false;
export function observeRateLimit(pacer) {
@@ -181,6 +181,67 @@ export default {
);
},
+ /**
+ * What "Refresh all" compares with the board, for every open pull in
+ * `repo` and every pull there updated since `closedSince` (a Date) and
+ * closed: number, state, updatedAt and, for open pulls, the head, draft,
+ * labels, comment and review counts. GraphQL, because one call returns
+ * 100 pulls with their counts, and its points come from a separate hourly
+ * budget from the REST calls the webhooks spend.
+ */
+ getPullsToCompare: async function (repo, closedSince) {
+ const [owner, name] = parseRepo(repo);
+ const open = { nodes: [], more: true, cursor: null };
+ const closed = { nodes: [], more: true, cursor: null };
+ // the first call reads both lists; later ones only the one with more
+ while (open.more || closed.more) {
+ const { repository } = await github.graphql(PULLS_TO_COMPARE_QUERY, {
+ owner,
+ name,
+ withOpen: open.more,
+ openCursor: open.cursor,
+ withClosed: closed.more,
+ closedCursor: closed.cursor,
+ });
+ if (open.more) {
+ takePage(open, repository.open, () => true);
+ }
+ if (closed.more) {
+ takePage(
+ closed,
+ repository.closed,
+ node => Date.parse(node.updatedAt) >= closedSince.getTime()
+ );
+ }
+ }
+ return open.nodes
+ .map(node => {
+ const head = node.commits.nodes[0];
+ return {
+ repo,
+ number: node.number,
+ state: 'open',
+ updatedAt: node.updatedAt,
+ draft: node.isDraft,
+ headSha: node.headRefOid,
+ headCommittedAt:
+ head && head.commit.oid === node.headRefOid ? head.commit.committedDate : null,
+ labels: node.labels.nodes.map(label => label.name),
+ comments: node.comments.totalCount,
+ reviews: node.reviews.totalCount,
+ };
+ })
+ .concat(
+ closed.nodes.map(node => ({
+ repo,
+ number: node.number,
+ state: 'closed',
+ updatedAt: node.updatedAt,
+ closedAt: node.closedAt,
+ }))
+ );
+ },
+
/**
* Best-effort: request `login` as a reviewer on a pull. Fires as the bot
* token — pulldasher keeps no per-user GitHub token — so GitHub records the
@@ -311,10 +372,8 @@ export default {
var comments = getIssueComments(repo, githubPull.number);
var headCommit = getCommit(repo, githubPull.head.sha);
var commitStatuses = getCommitStatuses(repo, githubPull.head.sha);
- var jobRuns = getAllJobRuns(repo, githubPull.head.sha);
+ var jobRuns = getCheckRuns(repo, githubPull.head.sha);
var events = getIssueEvents(repo, githubPull.number);
- // Only so we have the canonical list of labels.
- var ghIssue = getIssue(repo, githubPull.number);
var reviews = getReviews(repo, githubPull.number);
// Resolved (and cached) once per process; threaded through so
// deriveReviewRequests can tell a pulldasher claim apart from a
@@ -330,7 +389,6 @@ export default {
commitStatuses,
jobRuns,
events,
- ghIssue,
reviews,
botLogin,
diffStats,
@@ -341,10 +399,9 @@ export default {
commitStatuses = results[3],
jobRuns = results[4],
events = results[5],
- ghIssue = results[6],
- reviews = results[7],
- botLogin = results[8],
- fullPull = results[9];
+ reviews = results[6],
+ botLogin = results[7],
+ fullPull = results[8];
// graft the fields a list item lacks (diff stats + mergeable, see
// diffStats above). Omitting mergeable here re-writes it as NULL on
@@ -454,8 +511,13 @@ export default {
let allCommitStatuses = statuses.concat(checks);
- // Array of Label objects.
- const labels = getLabelsFromEvents(events, ghIssue);
+ // Array of Label objects. The pull carries the same labels as its
+ // issue, so there's no issues.get call for them.
+ const labels = getLabelsFromEvents(events, {
+ number: githubPull.number,
+ repo: repo,
+ labels: githubPull.labels,
+ });
// Array of { login, at, self }, one per current requested_reviewers
// entry -- see deriveReviewRequests.
@@ -709,7 +771,10 @@ function parseRepo(repo) {
*/
function getIssueEvents(repo, number) {
return logErrors(
- github.paginate(githubRest.issues.listEvents, params({ issue_number: number }, repo)),
+ github.paginate(
+ githubRest.issues.listEvents,
+ params({ issue_number: number, per_page: 100 }, repo)
+ ),
'Getting events for issue %s:%s',
repo,
number
@@ -718,7 +783,10 @@ function getIssueEvents(repo, number) {
function getIssueComments(repo, number) {
return logErrors(
- github.paginate(githubRest.issues.listComments, params({ issue_number: number }, repo)),
+ github.paginate(
+ githubRest.issues.listComments,
+ params({ issue_number: number, per_page: 100 }, repo)
+ ),
'Getting comments for issue %s:%s',
repo,
number
@@ -727,7 +795,10 @@ function getIssueComments(repo, number) {
function getReviews(repo, number) {
return logErrors(
- github.paginate(githubRest.pulls.listReviews, params({ pull_number: number }, repo)),
+ github.paginate(
+ githubRest.pulls.listReviews,
+ params({ pull_number: number, per_page: 100 }, repo)
+ ),
'Getting reviews for pull %s:%s',
repo,
number
@@ -736,7 +807,10 @@ function getReviews(repo, number) {
function getPullReviewComments(repo, number) {
return logErrors(
- github.paginate(githubRest.pulls.listReviewComments, params({ pull_number: number }, repo)),
+ github.paginate(
+ githubRest.pulls.listReviewComments,
+ params({ pull_number: number, per_page: 100 }, repo)
+ ),
'Getting pull review comments for pull %s:%s',
repo,
number
@@ -755,7 +829,7 @@ function getCommit(repo, sha) {
function getCommitStatuses(repo, ref) {
return logErrors(
githubRest.repos
- .getCombinedStatusForRef(params({ ref }, repo))
+ .getCombinedStatusForRef(params({ ref, per_page: 100 }, repo))
.then(res => res.data.statuses)
.then(statuses => statuses || []),
'Getting commit status for %s:%s',
@@ -764,6 +838,28 @@ function getCommitStatuses(repo, ref) {
);
}
+// One call lists every check run on the head commit. A fine-grained token
+// can't read check runs (1accc56c moved off this call for that), so after the
+// first refusal this process lists each workflow run's jobs instead, about 13
+// calls per iFixit/ifixit pull.
+let checkRunsRefused = false;
+
+function getCheckRuns(repo, ref) {
+ if (checkRunsRefused) {
+ return getAllJobRuns(repo, ref);
+ }
+ return github
+ .paginate(githubRest.checks.listForRef, params({ ref, per_page: 100 }, repo))
+ .catch(err => {
+ if (err.status !== 403 || !/not accessible/i.test(err.message)) {
+ throw err;
+ }
+ checkRunsRefused = true;
+ gitDebug('Check runs refused (%s), listing workflow jobs instead', err.message);
+ return getAllJobRuns(repo, ref);
+ });
+}
+
function getAllJobRuns(repo, ref) {
return logErrors(
github
@@ -773,6 +869,7 @@ function getAllJobRuns(repo, ref) {
{
head_sha: ref,
exclude_pull_requests: true,
+ per_page: 100,
},
repo
)
@@ -793,6 +890,7 @@ function getJobRunsFromWorkflow(workflowRun) {
params(
{
run_id: workflowRun.id,
+ per_page: 100,
},
workflowRun.repository.full_name
)
@@ -854,3 +952,52 @@ function hasNextPage(response) {
const link = response.headers && response.headers.link;
return Boolean(link) && link.includes('rel="next"');
}
+
+// Octokit's throttling runs GraphQL calls one at a time, a second apart, in
+// the lane REST writes (a claim's review request) also wait in, so one query
+// reads both lists and a press asks for one repo at a time.
+const PULLS_TO_COMPARE_QUERY = `query(
+ $owner: String!
+ $name: String!
+ $withOpen: Boolean!
+ $openCursor: String
+ $withClosed: Boolean!
+ $closedCursor: String
+) {
+ repository(owner: $owner, name: $name) {
+ open: pullRequests(states: OPEN, first: 100, after: $openCursor)
+ @include(if: $withOpen) {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ number
+ updatedAt
+ isDraft
+ headRefOid
+ labels(first: 100) { nodes { name } }
+ comments { totalCount }
+ reviews { totalCount }
+ commits(last: 1) { nodes { commit { oid committedDate } } }
+ }
+ }
+ closed: pullRequests(
+ states: [CLOSED, MERGED]
+ first: 100
+ after: $closedCursor
+ orderBy: { field: UPDATED_AT, direction: DESC }
+ ) @include(if: $withClosed) {
+ pageInfo { hasNextPage endCursor }
+ nodes { number updatedAt closedAt }
+ }
+ }
+}`;
+
+/**
+ * Add a page of a pullRequests connection to `list` while `keep` accepts its
+ * nodes, and note whether another page is worth reading.
+ */
+function takePage(list, page, keep) {
+ const kept = page.nodes.filter(keep);
+ list.nodes.push(...kept);
+ list.more = page.pageInfo.hasNextPage && kept.length === page.nodes.length;
+ list.cursor = page.pageInfo.endCursor;
+}
diff --git a/lib/pacer.js b/lib/pacer.js
index 9cbfb61c..b29d2e5d 100644
--- a/lib/pacer.js
+++ b/lib/pacer.js
@@ -11,12 +11,12 @@ const LOG_DELAY_THRESHOLD_MS = 5000;
/**
* A pacer proactively paces bulk GitHub requests so a backfill yields to live
* traffic instead of draining the shared token's quota to zero. It is a
- * per-process instance the entry point constructs and installs: only the CLI
- * backfill bins build a real one (`createPacer`); the server installs nothing
- * and runs against `noopPacer`, so live webhook/socket refreshes are never
- * delayed. Rate state is global to the token, so within a process every bulk
- * call gates against the same view, fed by `observe` on every response (live
- * calls included).
+ * per-process instance the entry point constructs and installs: the CLI
+ * backfill bins build a real one (`createPacer`), and so does the server for
+ * "Refresh all". Webhook/socket refreshes run against `noopPacer`, so live
+ * traffic is never delayed. Rate state is global to the token, so within a
+ * process every bulk call gates against the same view, fed by `observe` on
+ * every response (live calls included).
*
* `observe(headers)` — record the latest quota from any response (free).
* `gate()` — await this before each *bulk* unit of work (a consumer drain or a
@@ -38,7 +38,9 @@ export function createPacer({ reserve = config.github.bulkReserve ?? 1000 } = {}
return {
observe: function (headers) {
- if (!headers) {
+ // GraphQL and search answer with their own budgets in the same headers;
+ // only REST's (`core`) is the quota bulk work spends.
+ if (!headers || (headers["x-ratelimit-resource"] ?? "core") !== "core") {
return;
}
if (headers["x-ratelimit-remaining"] !== undefined) {
diff --git a/lib/pull-manager.js b/lib/pull-manager.js
index 7a35db7d..7d7bb8a9 100644
--- a/lib/pull-manager.js
+++ b/lib/pull-manager.js
@@ -45,6 +45,13 @@ const pullManager = {
notifyAboutPullStateChange(pull);
},
+
+ /** Send `event` to every connected board. */
+ broadcast: function (event, payload) {
+ sockets.forEach(function (socket) {
+ socket.emit(event, payload);
+ });
+ },
};
function sendInitialData(socket) {
diff --git a/lib/refresh-all.js b/lib/refresh-all.js
new file mode 100644
index 00000000..587f6707
--- /dev/null
+++ b/lib/refresh-all.js
@@ -0,0 +1,254 @@
+import gitManager from './git-manager.js';
+import dbManager from './db-manager.js';
+import debug from './debug.js';
+import { noopPacer } from './pacer.js';
+import { findMissingOpenPulls, findStaleOpenPulls, processPullItem } from './refresh.js';
+
+const log = debug('pulldasher:refresh-all');
+
+/**
+ * "Refresh all": one press brings the board to GitHub's state. It lists the
+ * open and recently closed pulls of every repo the board shows, compares them
+ * with the board, and refetches only the pulls that differ, one at a time.
+ * Each refetch waits for `pacer`, and webhook refreshes run on their own
+ * queue, so they never wait behind a press. One press runs at a time.
+ *
+ * `board()` returns the board's pulls (pull-manager), `repos` the configured
+ * repos, `closedSince()` the oldest close the board shows. `ready` resolves
+ * once the board has loaded from the DB. `onProgress` hears every change.
+ */
+export function createRefreshAll({
+ board,
+ repos,
+ closedSince,
+ pacer = noopPacer,
+ ready = Promise.resolve(),
+ onProgress = () => {},
+}) {
+ let progress = null;
+
+ function report(next) {
+ progress = next;
+ onProgress(next);
+ }
+
+ async function run() {
+ await ready;
+ const since = closedSince();
+ const listed = [];
+ const listedRepos = [];
+ let skipped = 0;
+ // one repo at a time: see PULLS_TO_COMPARE_QUERY in git-manager
+ for (const repo of coveredRepos(repos, board())) {
+ try {
+ listed.push(...(await gitManager.getPullsToCompare(repo, since)));
+ listedRepos.push(repo);
+ } catch (err) {
+ skipped++;
+ console.error(
+ 'Refresh all: failed to list the pulls in %s: %s',
+ repo,
+ (err && err.message) || err
+ );
+ }
+ }
+ const due = pullsToRefresh(board(), listed, listedRepos, since);
+ log('%s of %s listed pulls differ from the board', due.length, listed.length);
+ let done = 0;
+ let failed = 0;
+ report({ state: 'refreshing', done, total: due.length, failed, skipped });
+ for (const { repo, number, why } of due) {
+ await pacer.gate();
+ log('refreshing %s#%s (%s)', repo, number, why);
+ if (!(await refreshPull(repo, number))) {
+ failed++;
+ }
+ done++;
+ report({ state: 'refreshing', done, total: due.length, failed, skipped });
+ }
+ report({ state: 'done', done, total: due.length, failed, skipped });
+ }
+
+ return {
+ progress: () => progress,
+
+ /**
+ * Start a press unless one is running. Resolves when it finishes, or
+ * returns null when one was already running.
+ */
+ start() {
+ if (progress && progress.state !== 'done') {
+ return null;
+ }
+ report({ state: 'checking', done: 0, total: 0, failed: 0, skipped: 0 });
+ return run().catch(err => {
+ // a bug, not GitHub: count what it didn't get to as failed, so the
+ // button frees up and the board says to look at the log
+ console.error('Refresh all stopped: %s', (err && err.message) || err);
+ const unfinished = Math.max(1, progress.total - progress.done);
+ report({ ...progress, state: 'done', failed: progress.failed + unfinished });
+ });
+ },
+ };
+}
+
+/** Fetch, parse and save one pull. Resolves to whether it saved. */
+async function refreshPull(repo, number) {
+ let saved = true;
+ try {
+ const response = await gitManager.getPull(repo, number);
+ await new Promise(next =>
+ processPullItem(response, next, {
+ parse: gitManager.parse,
+ updateAllPullData: dbManager.updateAllPullData,
+ onFailure: () => (saved = false),
+ })
+ );
+ } catch (err) {
+ console.error(
+ 'Refresh all: failed to fetch pull %s in repo %s: %s',
+ number,
+ repo,
+ (err && err.message) || err
+ );
+ saved = false;
+ }
+ return saved;
+}
+
+/**
+ * The configured repos plus every repo with a pull on the board, once each.
+ * Webhooks arrive for the whole org, so the board holds pulls from repos the
+ * config doesn't list.
+ */
+export function coveredRepos(repos, boardPulls) {
+ const byName = new Map();
+ for (const name of repos.map(repo => repo.name).concat(boardPulls.map(pull => pull.data.repo))) {
+ if (!byName.has(name.toLowerCase())) {
+ byName.set(name.toLowerCase(), name);
+ }
+ }
+ return [...byName.values()];
+}
+
+/**
+ * The pulls where the board and GitHub disagree, each once, with the first
+ * reason found. `listed` is getPullsToCompare's output for `listedRepos`, the
+ * repos that answered; `closedSince` is the oldest close the board shows.
+ */
+export function pullsToRefresh(boardPulls, listed, listedRepos, closedSince) {
+ const key = (repo, number) => repo.toLowerCase() + '#' + number;
+ const onBoard = new Map(boardPulls.map(pull => [key(pull.data.repo, pull.data.number), pull]));
+ const boardOpen = boardPulls
+ .filter(pull => pull.isOpen())
+ .map(pull => ({ repo: pull.data.repo, number: pull.data.number }));
+ // shaped like pulls.list items, which the reconcile helpers read
+ const listedOpen = listed
+ .filter(pull => pull.state === 'open')
+ .map(pull => ({ number: pull.number, base: { repo: { full_name: pull.repo } } }));
+ const due = new Map();
+ const add = (repo, number, why) => {
+ if (!due.has(key(repo, number))) {
+ due.set(key(repo, number), { repo, number, why });
+ }
+ };
+
+ // open on GitHub but not on the board: a lost `opened` or `reopened`
+ for (const pull of findMissingOpenPulls(boardOpen, listedOpen)) {
+ add(pull.base.repo.full_name, pull.number, 'missing');
+ }
+ // open on the board but not on GitHub: a lost `closed`
+ for (const pull of findStaleOpenPulls(boardOpen, listedOpen, listedRepos)) {
+ add(pull.repo, pull.number, 'closed');
+ }
+ for (const pull of listed) {
+ const shown = onBoard.get(key(pull.repo, pull.number));
+ if (pull.state === 'closed') {
+ // closed recently enough to be on the board, and missing from it:
+ // opened and closed while webhooks weren't arriving
+ if (!shown && Date.parse(pull.closedAt) >= closedSince.getTime()) {
+ add(pull.repo, pull.number, 'missing');
+ }
+ } else if (shown && shown.isOpen()) {
+ const why = difference(shown, pull);
+ if (why) {
+ add(pull.repo, pull.number, why);
+ }
+ }
+ }
+ return [...due.values()];
+}
+
+/**
+ * The first way the board's copy of an open pull differs from GitHub's
+ * listing of it, or null when they agree.
+ */
+export function difference(pull, listed) {
+ if (pull.data.head.sha !== listed.headSha) {
+ return 'head';
+ }
+ if (Boolean(pull.data.draft) !== listed.draft) {
+ return 'draft';
+ }
+ const labels = pull.labels.map(label => label.data.title);
+ if (labels.length !== listed.labels.length || !labels.every(l => listed.labels.includes(l))) {
+ return 'labels';
+ }
+ // Counts catch a lost comment or review even after newer ones arrived,
+ // which a timestamp can't. The DB keeps deleted comments, so only more on
+ // GitHub counts as a difference.
+ if (listed.comments > pull.comments.filter(c => c.data.comment_type === 'issue').length) {
+ return 'comments';
+ }
+ if (listed.reviews > pull.reviews.length) {
+ return 'reviews';
+ }
+ if (Date.parse(listed.updatedAt) > newestKnown(pull)) {
+ return 'updated';
+ }
+ if (listed.headCommittedAt && lapsedStampStands(pull, Date.parse(listed.headCommittedAt))) {
+ return 'stamp';
+ }
+ // a check's result never moves updated_at, so a lost one shows only as a
+ // check the board still holds as running
+ if (pull.commitStatuses.some(status => status.data.state === 'pending')) {
+ return 'ci';
+ }
+ return null;
+}
+
+/**
+ * The newest time the board knows for a pull. GitHub's updated_at moves with
+ * every comment and review, but those webhooks don't touch the pull's row, so
+ * comparing with the row alone would refetch every pull whose newest activity
+ * is a comment.
+ */
+function newestKnown(pull) {
+ const times = [pull.data.updated_at]
+ .concat(pull.comments.map(comment => comment.data.created_at))
+ .concat(pull.reviews.map(review => review.data.submitted_at))
+ .map(time => (time ? new Date(time).getTime() : NaN))
+ .filter(Number.isFinite);
+ return times.length ? Math.max(...times) : 0;
+}
+
+/**
+ * A CR or QA stamp the board shows standing though it predates the head
+ * commit, which a refresh would lapse: a lost push, hidden because a later
+ * webhook already wrote the new head. GitHub approvals are exempt, as in
+ * git-manager's parse.
+ */
+function lapsedStampStands(pull, headCommittedAt) {
+ const approvals = new Set(
+ pull.reviews
+ .filter(review => String(review.data.state).toUpperCase() === 'APPROVED')
+ .map(review => review.data.review_id)
+ );
+ return pull.signatures.some(
+ sig =>
+ (sig.data.type === 'CR' || sig.data.type === 'QA') &&
+ sig.data.active === 1 &&
+ !approvals.has(sig.data.comment_id) &&
+ sig.data.created_at < headCommittedAt
+ );
+}
diff --git a/lib/refresh.js b/lib/refresh.js
index e09fec5e..fbd78677 100644
--- a/lib/refresh.js
+++ b/lib/refresh.js
@@ -133,6 +133,49 @@ export function createRefresh({ pacer = noopPacer } = {}) {
)
);
},
+
+ /**
+ * The cheap repair for lost webhooks: list each repo's open pulls
+ * (about one API call per repo, per 30 pulls) and refresh only the ones
+ * the DB has wrong. Open on GitHub but not in the DB means a lost
+ * `opened` or `reopened`; open in the DB but gone from the listing means
+ * a lost `closed`. openPulls repairs the same things but refetches every
+ * open pull on the way, which costs thousands of calls, so it runs only
+ * at startup.
+ */
+ reconcileOpenPulls: async function reconcileOpenPulls(repos) {
+ refreshDebug('reconcile open pulls');
+ const listedRepos = [];
+ const listing = await utils.forEachRepo(
+ repo =>
+ gitManager.getOpenPulls(repo, pacer).then(pulls => {
+ listedRepos.push(repo);
+ return pulls;
+ }),
+ { repos: repos }
+ );
+ let missing = [];
+ try {
+ missing = findMissingOpenPulls(await dbManager.getOpenPullIds(), listing.items);
+ } catch (err) {
+ console.error('Failed to load open pulls from the DB: %s', (err && err.message) || err);
+ }
+ refreshDebug('refreshing %s pulls open on GitHub but not in the DB', missing.length);
+ const report = await drainThrough(pullQueue)({
+ items: missing,
+ failedRepos: listing.failedRepos,
+ });
+ const staleFailures = await refreshStaleOpenPulls(
+ pullQueue,
+ pacer,
+ listing.items,
+ listedRepos
+ );
+ return {
+ failedRepos: report.failedRepos,
+ failedItems: report.failedItems.concat(staleFailures),
+ };
+ },
};
}
@@ -335,6 +378,16 @@ export function findStaleOpenPulls(dbOpenPulls, listedPulls, listedRepos) {
);
}
+/**
+ * The listed open pulls the DB doesn't hold as open: new or reopened pulls
+ * whose webhook never arrived. Repo names compare case-insensitively.
+ */
+export function findMissingOpenPulls(dbOpenPulls, listedPulls) {
+ const key = (repo, number) => repo.toLowerCase() + '#' + number;
+ const known = new Set(dbOpenPulls.map(pull => key(pull.repo, pull.number)));
+ return listedPulls.filter(pull => !known.has(key(pull.base.repo.full_name, pull.number)));
+}
+
/**
* A closed pull never appears in the open-pulls listing, so one whose close webhook
* was lost stays open in the DB until refetched. Never rejects: app.js ignores it.
diff --git a/shared/types.ts b/shared/types.ts
index 062542d4..d00d01e8 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -145,6 +145,18 @@ export interface InitializePayload {
weightLabels?: Record;
}
+/** "Refresh all" on the server, sent to every board: it checks GitHub, then
+ * refetches the pulls the board has wrong, one press at a time. */
+export interface RefreshAllProgress {
+ state: 'checking' | 'refreshing' | 'done';
+ /** pulls refetched so far, of the `total` that differed from GitHub */
+ done: number;
+ total: number;
+ /** pulls that couldn't be refetched, and repos GitHub didn't list */
+ failed: number;
+ skipped: number;
+}
+
export interface TokenResponse {
socketToken: string;
user: string;
diff --git a/test/git-manager-refresh.test.js b/test/git-manager-refresh.test.js
new file mode 100644
index 00000000..c6fb3731
--- /dev/null
+++ b/test/git-manager-refresh.test.js
@@ -0,0 +1,269 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import gitManager from "../lib/git-manager.js";
+
+// Stand in for api.github.com: each route answers by path (and, for GraphQL,
+// by query), and every call made is recorded as "METHOD /path".
+function fakeGithub(t, routes) {
+ const calls = [];
+ t.mock.method(globalThis, "fetch", async (url, init = {}) => {
+ const { pathname } = new URL(typeof url === "string" ? url : url.url);
+ const method = init.method || "GET";
+ calls.push(`${method} ${pathname}`);
+ const route = routes.find(([pattern]) => pattern.test(pathname));
+ const { status = 200, body } = route
+ ? route[1](init.body ? JSON.parse(init.body) : null)
+ : { status: 404, body: { message: "Not Found" } };
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+ });
+ return calls;
+}
+
+const repo = "/repos/test/repo-a";
+const job = {
+ name: "phpunit",
+ status: "completed",
+ conclusion: "success",
+ html_url: "https://github.com/test/repo-a/actions/runs/77/job/9",
+ started_at: "2026-09-20T10:02:00Z",
+ completed_at: "2026-09-20T10:09:00Z",
+};
+
+function parseRoutes(checkRuns) {
+ return [
+ [/^\/user$/, () => ({ body: { login: "pulldasher-bot" } })],
+ [/\/pulls\/3\/comments$/, () => ({ body: [] })],
+ [/\/issues\/3\/comments$/, () => ({ body: [] })],
+ [
+ /\/commits\/abc$/,
+ () => ({
+ body: { commit: { committer: { date: "2026-09-20T10:00:00Z" } } },
+ }),
+ ],
+ [/\/commits\/abc\/status$/, () => ({ body: { statuses: [] } })],
+ [/\/commits\/abc\/check-runs$/, checkRuns],
+ [
+ /\/actions\/runs$/,
+ () => ({
+ body: {
+ total_count: 1,
+ workflow_runs: [{ id: 77, repository: { full_name: "test/repo-a" } }],
+ },
+ }),
+ ],
+ [
+ /\/actions\/runs\/77\/jobs$/,
+ () => ({ body: { total_count: 1, jobs: [job] } }),
+ ],
+ [
+ /\/issues\/3\/events$/,
+ () => ({
+ body: [
+ {
+ event: "labeled",
+ label: { name: "size: S" },
+ actor: { login: "labeler" },
+ created_at: "2026-09-20T10:01:00Z",
+ },
+ ],
+ }),
+ ],
+ [/\/pulls\/3\/reviews$/, () => ({ body: [] })],
+ ];
+}
+
+// A pulls.get response, so parse fetches no diff stats.
+const githubPull = () => ({
+ number: 3,
+ state: "open",
+ title: "t",
+ body: "",
+ draft: false,
+ created_at: "2026-09-19T00:00:00Z",
+ updated_at: "2026-09-20T10:01:00Z",
+ user: { login: "author" },
+ labels: [{ name: "size: S" }],
+ requested_reviewers: [],
+ assignees: [],
+ milestone: null,
+ head: {
+ ref: "f",
+ sha: "abc",
+ repo: { name: "repo-a", owner: { login: "test" } },
+ },
+ base: { ref: "master", repo: { full_name: "test/repo-a" } },
+ additions: 1,
+ deletions: 1,
+ changed_files: 1,
+ mergeable: true,
+});
+
+test("parse reads the checks in one call and the labels from the pull itself", async (t) => {
+ const calls = fakeGithub(
+ t,
+ parseRoutes(() => ({ body: { total_count: 1, check_runs: [job] } }))
+ );
+
+ const pull = await gitManager.parse(githubPull());
+
+ assert.deepEqual(
+ pull.commitStatuses.map((s) => `${s.data.context}=${s.data.state}`),
+ ["phpunit=success"]
+ );
+ assert.deepEqual(
+ pull.labels.map((l) => `${l.data.title} by ${l.data.user}`),
+ ["size: S by labeler"]
+ );
+ assert.ok(
+ !calls.includes(`GET ${repo}/issues/3`),
+ "no issues.get for the labels"
+ );
+ assert.ok(
+ !calls.some((call) => call.includes("/actions/")),
+ "no workflow runs or jobs"
+ );
+});
+
+// Fine-grained tokens can't read check runs (1accc56c): GitHub answers 403,
+// and from then on this process lists each workflow run's jobs instead.
+test("parse lists workflow jobs once check runs are refused", async (t) => {
+ const calls = fakeGithub(
+ t,
+ parseRoutes(() => ({
+ status: 403,
+ body: { message: "Resource not accessible by personal access token" },
+ }))
+ );
+
+ const first = await gitManager.parse(githubPull());
+ const second = await gitManager.parse(githubPull());
+
+ for (const pull of [first, second]) {
+ assert.deepEqual(
+ pull.commitStatuses.map(
+ (s) => `${s.data.context}=${s.data.state} ${s.data.target_url}`
+ ),
+ [`phpunit=success ${job.html_url}`]
+ );
+ }
+ assert.equal(calls.filter((call) => call.endsWith("/check-runs")).length, 1);
+});
+
+test("getPullsToCompare reads both lists in one call, then only the one with more", async (t) => {
+ const node = (number, extra = {}) => ({
+ number,
+ updatedAt: "2026-09-28T00:00:00Z",
+ isDraft: false,
+ headRefOid: `sha${number}`,
+ labels: { nodes: [{ name: "size: S" }] },
+ comments: { totalCount: 2 },
+ reviews: { totalCount: 1 },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ oid: `sha${number}`,
+ committedDate: "2026-09-27T00:00:00Z",
+ },
+ },
+ ],
+ },
+ ...extra,
+ });
+ const connection = (nodes, endCursor) => ({
+ pageInfo: { hasNextPage: Boolean(endCursor), endCursor },
+ nodes,
+ });
+ const asked = [];
+ fakeGithub(t, [
+ [
+ /^\/graphql$/,
+ ({ variables }) => {
+ asked.push(
+ `open ${variables.withOpen} ${variables.openCursor}, closed ${variables.withClosed}`
+ );
+ const repository = {};
+ if (variables.withOpen) {
+ repository.open = variables.openCursor
+ ? connection([
+ node(2, {
+ commits: {
+ nodes: [{ commit: { oid: "old", committedDate: "x" } }],
+ },
+ }),
+ ])
+ : connection([node(1)], "c1");
+ }
+ if (variables.withClosed) {
+ // newest update first: #4 is older than the cutoff, so page 2 (empty
+ // here) is never asked for
+ repository.closed = variables.closedCursor
+ ? connection([])
+ : connection(
+ [
+ {
+ number: 3,
+ updatedAt: "2026-09-27T00:00:00Z",
+ closedAt: "2026-09-26T00:00:00Z",
+ },
+ {
+ number: 4,
+ updatedAt: "2026-09-01T00:00:00Z",
+ closedAt: "2026-09-01T00:00:00Z",
+ },
+ ],
+ "c2"
+ );
+ }
+ return { body: { data: { repository } } };
+ },
+ ],
+ ]);
+
+ const pulls = await gitManager.getPullsToCompare(
+ "test/repo-a",
+ new Date("2026-09-18T00:00:00Z")
+ );
+
+ assert.deepEqual(asked, [
+ "open true null, closed true",
+ "open true c1, closed false",
+ ]);
+ assert.deepEqual(pulls, [
+ {
+ repo: "test/repo-a",
+ number: 1,
+ state: "open",
+ updatedAt: "2026-09-28T00:00:00Z",
+ draft: false,
+ headSha: "sha1",
+ headCommittedAt: "2026-09-27T00:00:00Z",
+ labels: ["size: S"],
+ comments: 2,
+ reviews: 1,
+ },
+ {
+ repo: "test/repo-a",
+ number: 2,
+ state: "open",
+ updatedAt: "2026-09-28T00:00:00Z",
+ draft: false,
+ headSha: "sha2",
+ // the last commit listed isn't the head, so its date says nothing
+ headCommittedAt: null,
+ labels: ["size: S"],
+ comments: 2,
+ reviews: 1,
+ },
+ {
+ repo: "test/repo-a",
+ number: 3,
+ state: "closed",
+ updatedAt: "2026-09-27T00:00:00Z",
+ closedAt: "2026-09-26T00:00:00Z",
+ },
+ ]);
+});
diff --git a/test/pacer.test.js b/test/pacer.test.js
index 6077151e..003da950 100644
--- a/test/pacer.test.js
+++ b/test/pacer.test.js
@@ -129,3 +129,23 @@ test("createPacer spaces consecutive gates by observed consumption", async () =>
"second gate should pace out the request spent since the last gate"
);
});
+
+// GraphQL and search answer with their own budgets in the same headers. A
+// pacer that read them as REST's would pause bulk work for a quota it isn't
+// spending, here until a reset a few seconds out (short, so a regression fails
+// instead of holding the run open).
+test("createPacer ignores the GraphQL budget's headers", async () => {
+ const pacer = createPacer({ reserve: 10 });
+ pacer.observe({
+ "x-ratelimit-resource": "graphql",
+ "x-ratelimit-remaining": "0",
+ "x-ratelimit-reset": String(Math.ceil(Date.now() / 1000) + 3),
+ "x-ratelimit-used": "5000",
+ });
+
+ const paused = await Promise.race([
+ pacer.gate().then(() => false),
+ new Promise((resolve) => setTimeout(() => resolve(true), 1000)),
+ ]);
+ assert.equal(paused, false);
+});
diff --git a/test/refresh-all.test.js b/test/refresh-all.test.js
new file mode 100644
index 00000000..a584b4ad
--- /dev/null
+++ b/test/refresh-all.test.js
@@ -0,0 +1,303 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import gitManager from "../lib/git-manager.js";
+import dbManager from "../lib/db-manager.js";
+import Pull from "../models/pull.js";
+import {
+ coveredRepos,
+ createRefreshAll,
+ pullsToRefresh,
+} from "../lib/refresh-all.js";
+
+const CLOSED_SINCE = new Date("2026-09-18T00:00:00Z");
+const HEAD_COMMITTED = "2026-09-20T10:00:00Z";
+
+// A pull as the board holds it (pull-manager keeps Pull objects loaded from
+// the DB), in step with listed() below unless a test changes one side.
+function shown({
+ repo = "test/repo-a",
+ number = 1,
+ state = "open",
+ sha = "head1",
+ draft = false,
+ updatedAt = "2026-09-20T12:00:00Z",
+ labels = ["size: S"],
+ comments = ["2026-09-20T11:00:00Z"],
+ reviews = [],
+ signatures = [],
+ statuses = ["success"],
+} = {}) {
+ const user = { login: "someone" };
+ return new Pull(
+ {
+ repo,
+ number,
+ state,
+ draft,
+ updated_at: new Date(updatedAt),
+ head: { sha },
+ user: { login: "author" },
+ cr_req: 1,
+ qa_req: 1,
+ },
+ signatures.map((sig) => ({
+ data: { user, active: 1, ...sig, created_at: new Date(sig.at) },
+ })),
+ comments.map((at, i) => ({
+ data: {
+ comment_type: "issue",
+ comment_id: i,
+ created_at: new Date(at),
+ user,
+ },
+ })),
+ reviews.map((review) => ({
+ data: { ...review, submitted_at: new Date(review.at), user },
+ })),
+ statuses.map((state) => ({ data: { state } })),
+ labels.map((title) => ({ data: { title } }))
+ );
+}
+
+// The same pull as getPullsToCompare lists it.
+function listed({
+ repo = "test/repo-a",
+ number = 1,
+ sha = "head1",
+ draft = false,
+ updatedAt = "2026-09-20T12:00:00Z",
+ labels = ["size: S"],
+ comments = 1,
+ reviews = 0,
+} = {}) {
+ return {
+ repo,
+ number,
+ state: "open",
+ updatedAt,
+ draft,
+ headSha: sha,
+ headCommittedAt: HEAD_COMMITTED,
+ labels,
+ comments,
+ reviews,
+ };
+}
+
+const closedListing = (number, closedAt, repo = "test/repo-a") => ({
+ repo,
+ number,
+ state: "closed",
+ updatedAt: closedAt,
+ closedAt,
+});
+
+function due(board, listing, listedRepos = ["test/repo-a"]) {
+ return pullsToRefresh(board, listing, listedRepos, CLOSED_SINCE).map(
+ (pull) => `${pull.repo}#${pull.number} ${pull.why}`
+ );
+}
+
+test("a pull that matches GitHub isn't refreshed", () => {
+ assert.deepEqual(due([shown()], [listed()]), []);
+});
+
+test("a pull open on GitHub but missing from the board is refreshed", () => {
+ assert.deepEqual(due([], [listed({ number: 7 })]), ["test/repo-a#7 missing"]);
+});
+
+test("a pull the board holds closed that GitHub lists open is refreshed", () => {
+ assert.deepEqual(due([shown({ state: "closed" })], [listed()]), [
+ "test/repo-a#1 missing",
+ ]);
+});
+
+test("a pull the board holds open that GitHub doesn't list open is refreshed", () => {
+ const board = [
+ shown({ number: 2 }),
+ shown({ repo: "test/repo-b", number: 3 }),
+ ];
+
+ // repo-b's listing failed, which proves nothing about its pulls
+ assert.deepEqual(due(board, []), ["test/repo-a#2 closed"]);
+});
+
+test("a pull closed since the cutoff and missing from the board is refreshed", () => {
+ const listing = [
+ closedListing(8, "2026-09-27T10:00:00Z"),
+ // closed before the board's cutoff: the board wouldn't show it
+ closedListing(9, "2026-09-01T10:00:00Z"),
+ ];
+
+ assert.deepEqual(due([], listing), ["test/repo-a#8 missing"]);
+ // already on the board: nothing to add
+ assert.deepEqual(
+ due([shown({ number: 8, state: "closed" })], listing.slice(0, 1)),
+ []
+ );
+});
+
+test("a pull pushed to since the board read it is refreshed", () => {
+ assert.deepEqual(due([shown()], [listed({ sha: "head2" })]), [
+ "test/repo-a#1 head",
+ ]);
+});
+
+test("a pull marked ready for review since the board read it is refreshed", () => {
+ assert.deepEqual(due([shown({ draft: true })], [listed()]), [
+ "test/repo-a#1 draft",
+ ]);
+});
+
+test("a pull labeled since the board read it is refreshed", () => {
+ assert.deepEqual(
+ due([shown()], [listed({ labels: ["size: S", "project:x"] })]),
+ ["test/repo-a#1 labels"]
+ );
+});
+
+test("a pull with a comment the board missed is refreshed, even after newer ones arrived", () => {
+ const board = [
+ shown({ comments: ["2026-09-20T11:00:00Z", "2026-09-21T09:00:00Z"] }),
+ ];
+
+ assert.deepEqual(
+ due(board, [listed({ comments: 3, updatedAt: "2026-09-21T09:00:00Z" })]),
+ ["test/repo-a#1 comments"]
+ );
+ // the DB keeps deleted comments, so more on the board than on GitHub is fine
+ assert.deepEqual(
+ due(board, [listed({ comments: 1, updatedAt: "2026-09-21T09:00:00Z" })]),
+ []
+ );
+});
+
+test("a pull with a review the board missed is refreshed", () => {
+ assert.deepEqual(due([shown()], [listed({ reviews: 1 })]), [
+ "test/repo-a#1 reviews",
+ ]);
+});
+
+test("a pull GitHub updated after everything the board knows is refreshed", () => {
+ assert.deepEqual(
+ due([shown()], [listed({ updatedAt: "2026-09-21T00:00:00Z" })]),
+ ["test/repo-a#1 updated"]
+ );
+});
+
+// Comment and review webhooks never touch the pull's row, but GitHub's
+// updated_at moves with them: the newest comment counts as known.
+test("a pull whose newest activity is a comment the board has isn't refreshed", () => {
+ const board = [shown({ comments: ["2026-09-22T08:00:00Z"] })];
+
+ assert.deepEqual(
+ due(board, [listed({ updatedAt: "2026-09-22T08:00:00Z" })]),
+ []
+ );
+});
+
+test("a stamp the board shows standing though it predates the head commit is refreshed", () => {
+ const stamp = { type: "CR", comment_id: 50, at: "2026-09-20T09:00:00Z" };
+
+ assert.deepEqual(due([shown({ signatures: [stamp] })], [listed()]), [
+ "test/repo-a#1 stamp",
+ ]);
+ // an approval GitHub kept across the push stays standing, as parse() keeps it
+ const reviews = [
+ { review_id: 50, state: "APPROVED", at: "2026-09-20T09:00:00Z" },
+ ];
+ assert.deepEqual(
+ due([shown({ signatures: [stamp], reviews })], [listed({ reviews: 1 })]),
+ []
+ );
+});
+
+test("a pull whose checks the board still shows running is refreshed", () => {
+ assert.deepEqual(
+ due([shown({ statuses: ["success", "pending"] })], [listed()]),
+ ["test/repo-a#1 ci"]
+ );
+});
+
+test("coveredRepos adds every repo the board shows to the configured ones", () => {
+ const board = [
+ shown({ repo: "iFixit/ops" }),
+ shown({ repo: "ifixit/ifixit" }),
+ ];
+
+ assert.deepEqual(coveredRepos([{ name: "iFixit/ifixit" }], board), [
+ "iFixit/ifixit",
+ "iFixit/ops",
+ ]);
+});
+
+// A press lists the board's repos, refetches only what differs, one pull per
+// pacer slot, and tells every board how far it got.
+test("a press refreshes only the pulls that differ and reports its progress", async (t) => {
+ const board = [
+ shown({ number: 1 }),
+ shown({ repo: "iFixit/ops", number: 2 }),
+ ];
+ const listedRepos = [];
+ t.mock.method(gitManager, "getPullsToCompare", (repo) => {
+ listedRepos.push(repo);
+ return Promise.resolve(
+ repo === "test/repo-a"
+ ? [listed({ number: 1 }), listed({ number: 5 })]
+ : []
+ );
+ });
+ const fetched = [];
+ t.mock.method(gitManager, "getPull", (repo, number) => {
+ fetched.push(`${repo}#${number}`);
+ if (number === 2) return Promise.reject(new Error("transient 502"));
+ return Promise.resolve({ number, base: { repo: { full_name: repo } } });
+ });
+ t.mock.method(gitManager, "parse", (response) => Promise.resolve(response));
+ t.mock.method(dbManager, "updateAllPullData", () => Promise.resolve());
+ t.mock.method(console, "error", () => {});
+ let gates = 0;
+ const progress = [];
+ const press = createRefreshAll({
+ board: () => board,
+ repos: [{ name: "test/repo-a" }],
+ closedSince: () => CLOSED_SINCE,
+ pacer: { gate: () => Promise.resolve(gates++) },
+ onProgress: (p) =>
+ progress.push(`${p.state} ${p.done}/${p.total} failed ${p.failed}`),
+ });
+
+ await press.start();
+
+ assert.deepEqual(listedRepos, ["test/repo-a", "iFixit/ops"]);
+ assert.deepEqual(fetched, ["test/repo-a#5", "iFixit/ops#2"]);
+ assert.equal(gates, 2);
+ assert.deepEqual(progress, [
+ "checking 0/0 failed 0",
+ "refreshing 0/2 failed 0",
+ "refreshing 1/2 failed 0",
+ "refreshing 2/2 failed 1",
+ "done 2/2 failed 1",
+ ]);
+});
+
+test("a second press while one runs doesn't start another", async (t) => {
+ let listings = 0;
+ t.mock.method(gitManager, "getPullsToCompare", () => {
+ listings++;
+ return Promise.resolve([]);
+ });
+ const press = createRefreshAll({
+ board: () => [],
+ repos: [{ name: "test/repo-a" }],
+ closedSince: () => CLOSED_SINCE,
+ });
+
+ const first = press.start();
+ assert.equal(press.start(), null);
+ await first;
+ await press.start();
+
+ assert.equal(listings, 2);
+ assert.equal(press.progress().state, "done");
+});
diff --git a/test/stale-open-pulls.test.js b/test/stale-open-pulls.test.js
index fea759a6..f3876120 100644
--- a/test/stale-open-pulls.test.js
+++ b/test/stale-open-pulls.test.js
@@ -2,7 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import gitManager from "../lib/git-manager.js";
import dbManager from "../lib/db-manager.js";
-import { createRefresh, findStaleOpenPulls } from "../lib/refresh.js";
+import { createRefresh, findMissingOpenPulls, findStaleOpenPulls } from "../lib/refresh.js";
const githubPull = (repo, number) => ({ number, base: { repo: { full_name: repo } } });
@@ -26,6 +26,14 @@ test("findStaleOpenPulls keeps DB-open pulls missing from a successful listing",
]);
});
+test("findMissingOpenPulls keeps listed pulls the DB doesn't hold as open", () => {
+ const listed = [githubPull("Test/Repo-A", 1), githubPull("test/repo-a", 2)];
+
+ const missing = findMissingOpenPulls([{ repo: "test/repo-a", number: 1 }], listed);
+
+ assert.deepEqual(missing, [githubPull("test/repo-a", 2)]);
+});
+
// A pull whose close webhook was lost stays open in the DB. openPulls refetches it,
// skipping repos whose listing failed and reporting a refetch that fails.
test("openPulls refetches pulls the DB holds as open that GitHub no longer lists", async (t) => {