Skip to content

Commit 90bc476

Browse files
msoginclaude
andauthored
GLOOK-50: stop the merged-PR search from manufacturing false skips (#71)
* GLOOK-50: stop the merged-PR search from manufacturing false skips The 2026-09-09 dev run aborted at 21%: ABORT (GLOOK-13): 21 of 100 engineers couldn't be fetched (21%). Most failures are GitHub search timeouts (21 of 21) The guard was right to fire on what it was told, but ~19 of those 21 skips were false and the previous commit caused them. Verified against GitHub: sl-chromatic-bot, sl-data-team-jenkins, magdalenastaller, keser, flaksie, gkim-smartling and viakivchuk-smartling all have GENUINELY zero merged PRs — bots and non-engineers — and an EMPTY issue search routinely reports incomplete_results=true while being perfectly correct. Three mistakes, all mine, in how the merged-PR path was hardened: - it got the raise but not the retry ladder the commit path has, so one transient timeout went straight to a SKIP; - it had no partial/uncertain outcome, only "trusted" or "skipped"; - it assumed incomplete_results carries the same meaning on issue search as on commit search. It does not. This is the failure the PR #70 reviewer predicted on the abort-pressure thread — that hardening these paths would make the abort cliff more likely, not less. I argued the partial-data channel had dissolved that tension. It had not, because I never extended the channel to this path. The endpoints are now deliberately asymmetric: - Commit search stays strict: retry, narrow the window, then raise. There an empty timed-out page really did hide 43 commits. - Merged-PR and review-count searches retry, then raise ONLY on a self-contradicting page (total_count > 0 with empty items) — the shape that would report a developer's 42 merged PRs as 0, seen in the same run for ksoloviov-smartling (3) and kbroadrick-smartling (2). A persistent timed-out EMPTY result keeps the zero and records `prsUnverified` through integrity.recordError, so the member stays in the report, the doubt is visible in run_metadata.errors, and the abort gate is not touched. The retry ladder is now one shared helper rather than duplicated per endpoint, with the per-endpoint policy at the call site where the asymmetry is explained. CLAUDE.md records the asymmetry and says not to unify the two policies, since that is exactly the tidy-up that would reintroduce this. 126 suites / 1231 tests pass; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * GLOOK-50: give the recorded doubt a consumer Review found the central claim of the previous commit was half true. "The member stays in the report, the doubt is visible in run_metadata.errors" held for the DB blob and for no human surface: - evaluateIntegrity is typed Pick<RunMetadata, 'skipped'|'expectedCount'| 'thresholds'>, so it structurally could not read errors; - IntegrityBadge returns null at state === 'ok'; - getOrgReport never SELECTed run_metadata, closing the last surface. So an org-wide issue-search brownout — the 2026-09-09 shape, but affecting every member — produced zero skips, state 'ok', no badge, and every developer reading 0 merged PRs with the largest single term in the impact score silently removed. Leniency without a consumer is worse than the raise it replaced. Unverified-ness is now its own typed, counted channel: - RunMetadata.unverified: UnverifiedMember[] {login, field, kept, reason}, deliberately NOT an IntegrityError — `errors` already mixes per-commit sha-merge-check and unmerged-commit-detail entries running to hundreds on a healthy run, so anything thresholded on errors.length would cry wolf and be switched off; - evaluateIntegrity counts distinct unverified logins and downgrades at degradedUnverifiedPct (15%), with allowlisted members out of the denominator as elsewhere. It can never abort on them: those members are present in the report; - IntegrityBadge renders at state === 'ok' when the list is non-empty; - getOrgReport selects run_metadata. Also fixed, all found by review: - countReviewedPRs kept the zero and recorded NOTHING — a strict regression against pre-PR behaviour, which at least raised into recordError. It now returns {reviews, unverified}. Its comment already noted per_page:1 makes the contradiction arm unreachable, which had made the sole raise condition dead and the endpoint trust everything. - A page-2 timeout truncated a paginated PR set: page 1 says 250, page 2 times out, 100 returned as a routine "unverified" zero. That is the GLOOK-50 undercount moved from page 1 to page N. expectedTotal is now monotonic as in collectCommits, leniency requires nothing collected AND nothing promised, and the walk reconciles against the total. - The contradiction raise no longer breaks the brownout attribution added in #70: its wording had stopped matching the matcher, so a contradiction-shaped brownout would have told the on-call to rotate the PAT. Messages and matcher are now pinned together by tests, which nothing did before. - collectCommits still carried a verbatim copy of the retry ladder the previous commit claimed to have extracted; it now uses searchPageWithRetry. - Test drain helpers stopped at the first tick with no pending timer, which stranded the promise once another sleep entered the path. Settle-aware now. - `last as T`, the `const res = { data }` shim, and doc comments that had drifted onto the wrong functions. Tests: integrity-level cases assert the OUTCOME rather than the internal field — brownout downgrades to degraded, never aborts, crosses at 15%, dedupes a login with two unverified figures, excludes allowlisted from the denominator — plus message/matcher coupling for every raise site. 126 suites / 1241 tests pass; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: drop an unrelated file swept in by `git add -A` src/lib/llm-config/index.ts is an unused barrel re-export — nothing imports `@/lib/llm-config`, and service.ts was already tracked. It was untracked in the working copy and got picked up by a broad `git add -A src/lib`, which put an unrelated file in a GLOOK-50 commit. Untracked again; the file stays on disk exactly as it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: msogin <msogin@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e71f4f6 commit 90bc476

12 files changed

Lines changed: 526 additions & 63 deletions

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Glooker is a Next.js 15 web app that generates developer impact reports for a Gi
5454
- SQLite SQL translator handles `INSERT IGNORE`, `ON DUPLICATE KEY UPDATE`, and `NOW()` — if adding new MySQL-specific SQL, update `translateSQL()` in `db/sqlite.ts`
5555
- Progress store and stop-signal store use `globalThis` to survive Next.js HMR module reloads
5656
- `@octokit/rest` is ESM-only — any test file that imports from `github.ts` (directly or transitively) must mock it before the import, and it must be the **factory** form: `jest.mock('@octokit/rest', () => ({ Octokit: jest.fn().mockImplementation(() => ({})) }))`. A bare `jest.mock('@octokit/rest')` does **not** work — auto-mocking still loads the real module, so the suite dies with "Jest encountered an unexpected token" pointing at `dist-src/index.js`, which reads like a transform-config problem rather than a mocking one.
57-
- **A GitHub search that succeeds can still be lying (GLOOK-50).** `incomplete_results: true` means the query timed out and GitHub returned only what it had found by then — so an **empty** page with that flag is an unreliable zero, not a real one. That is how six developers were recorded with 0 commits while GitHub held 60 between them, with no skip and no integrity warning. The rules, all in `github.ts`: a timeout with **items** is deliberately NOT distrusted (GitHub warns a timeout "does not necessarily mean that search results are incomplete", and on the run that produced this code 5 of 6 flagged pages were fine); an untrustworthy **empty** page is retried twice, then the date window is halved (`splitDateWindow`, max 2 levels, second half keeps an open upper bound so nothing falls off the end), and only then does it **raise** — a raise becomes a counted SKIP, which is the goal, not a workaround. Collected hits are reconciled against `total_count` **clamped to** the 1000-result cap, never gated on it. Partial-but-real data is **kept** and reported as a `SearchShortfall` → `integrity.recordError` (member-kept partial data), never discarded — throwing away 90 of 100 commits would be worse than shipping them slightly low. Pagination stops at 1000 results because paging past it returns `Only the first 1000 search results are available`.
57+
- **A GitHub search that succeeds can still be lying (GLOOK-50).** `incomplete_results: true` means the query timed out and GitHub returned only what it had found by then — so an **empty** page with that flag is an unreliable zero, not a real one. That is how six developers were recorded with 0 commits while GitHub held 60 between them, with no skip and no integrity warning. The rules, all in `github.ts`: a timeout with **items** is deliberately NOT distrusted (GitHub warns a timeout "does not necessarily mean that search results are incomplete", and on the run that produced this code 5 of 6 flagged pages were fine); an untrustworthy **empty** page is retried twice, then the date window is halved (`splitDateWindow`, max 2 levels, second half keeps an open upper bound so nothing falls off the end), and only then does it **raise** — a raise becomes a counted SKIP, which is the goal, not a workaround. Collected hits are reconciled against `total_count` **clamped to** the 1000-result cap, never gated on it. Partial-but-real data is **kept** and reported as a `SearchShortfall` → `integrity.recordError` (member-kept partial data), never discarded — throwing away 90 of 100 commits would be worse than shipping them slightly low. Pagination stops at 1000 results because paging past it returns `Only the first 1000 search results are available`. **The endpoints are deliberately asymmetric, and this is the thing most likely to be "tidied" into a bug:** commit search stays strict (an empty timed-out page really did hide 43 commits), but for **issue/PR search an empty result routinely reports `incomplete_results: true` while being correct** — true for bots and anyone with genuinely zero merged PRs. Raising there produced 19 false skips in one run and aborted the report at 21%. So merged-PR and review-count searches retry, then raise **only** when the loss is provable — a self-contradicting page (`total_count > 0` with empty `items`), or a mid-pagination timeout after page 1 already told us how many exist. A timed-out result with **nothing collected and nothing promised** keeps the zero. **Whenever a figure is kept but unproven, it must land on the typed `unverified` channel** (`RunMetadata.unverified`, via `integrity.recordUnverified`) — never only in `errors`, which already carries hundreds of per-commit entries on a healthy run and so cannot be thresholded. `evaluateIntegrity` counts distinct unverified logins and downgrades to `degraded` at `degradedUnverifiedPct` (15%); it can never abort on them, because those members are present in the report. `IntegrityBadge` renders them even at `state === 'ok'`. Without that chain an org-wide issue-search brownout produces zero skips and ships a green report in which every developer reads 0 merged PRs. Do not unify the two policies.
5858
- **Report integrity (GLOOK-13/48): `countableSkips()` and `integrityCounts()` in `report-runner/types.ts` are the only place "which skips count" is defined.** `evaluateIntegrity`, the runner's abort message, and `IntegrityBadge` all read from them — the rule previously existed as four hand-written filters, and when one drifted the guard went green while reports lost half the org. Only `expected` (human-allowlisted via `report_skip_allowlist`) is excluded, and allowlisted members are removed from the **denominator** too, so growing the allowlist can't dilute the percentage gate. `auto-flagged` is only a *suggestion* for a human to promote in Settings — it must never silence the guard. If you add a `SkipClassification`, `COUNTABLE_SKIP_CLASSIFICATIONS` is an explicit inclusion list precisely so that becomes a deliberate decision.
5959
- Tests use Jest + ts-jest with `@/` path alias — config in `jest.config.ts`
6060
- CI runs on all pull requests and pushes to main (`.github/workflows/test.yml`)

‎src/components/IntegrityBadge.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,25 @@ function classificationLabel(c: SkippedMember['classification']): string {
1616
export default function IntegrityBadge({ metadata }: IntegrityBadgeProps) {
1717
const [open, setOpen] = useState(false);
1818

19-
if (!metadata || metadata.state === 'ok') return null;
19+
const unverified = metadata?.unverified ?? [];
20+
21+
// GLOOK-50: a run can be 'ok' and still be hiding unverified figures — an
22+
// org-wide issue-search brownout produces zero skips, so without this the
23+
// report renders completely clean while every developer shows 0 merged PRs.
24+
if (!metadata || (metadata.state === 'ok' && unverified.length === 0)) return null;
25+
26+
if (metadata.state === 'ok') {
27+
return (
28+
<button
29+
type="button"
30+
onClick={() => setOpen(o => !o)}
31+
className={`${PILL_BASE} bg-sky-500/15 text-sky-300 border border-sky-500/30 hover:bg-sky-500/25 transition-colors`}
32+
title={unverified.map(u => `@${u.login} ${u.field}: ${u.reason}`).join('\n')}
33+
>
34+
ⓘ {unverified.length} unverified
35+
</button>
36+
);
37+
}
2038

2139
const expectedCount = metadata.expectedCount ?? 0;
2240

‎src/lib/__tests__/unit/integrity-guard-regression.test.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
*/
2525
import { evaluateIntegrity } from '@/lib/report-runner/skip-classifier';
2626
import { IntegrityTracker } from '@/lib/report-runner/integrity-tracker';
27-
import { DEFAULT_THRESHOLDS, countableSkips, integrityCounts, formatIntegrityAbortReason } from '@/lib/report-runner/types';
27+
import { DEFAULT_THRESHOLDS, countableSkips, integrityCounts, formatIntegrityAbortReason, unverifiedCounts } from '@/lib/report-runner/types';
28+
import type { UnverifiedMember } from '@/lib/report-runner/types';
2829
import type { SkipClassification } from '@/lib/report-runner/types';
2930

3031
function snapshotWith(expectedCount: number, skips: Array<[string, SkipClassification]>) {
@@ -160,3 +161,73 @@ describe('allowlisted members leave the denominator, not just the numerator', ()
160161
expect(evaluateIntegrity(snap)).toBe('ok');
161162
});
162163
});
164+
165+
/**
166+
* GLOOK-50: an unverified member is kept in the report, so it must never abort
167+
* a run — but it must also not be invisible. Before this, an org-wide
168+
* issue-search brownout produced ZERO skips, so the state stayed 'ok', the
169+
* badge returned null, and every developer showed 0 merged PRs with their
170+
* impact score silently reshuffled. The doubt was written to run_metadata and
171+
* read by nothing.
172+
*/
173+
function withUnverified(expectedCount: number, n: number, field: UnverifiedMember['field'] = 'merged-prs') {
174+
return {
175+
expectedCount,
176+
thresholds: DEFAULT_THRESHOLDS,
177+
skipped: [] as never[],
178+
unverified: Array.from({ length: n }, (_, i) => ({
179+
login: `u${i}`, field, kept: 0, reason: 'search timed out',
180+
})),
181+
};
182+
}
183+
184+
describe('unverified members downgrade a run without aborting it', () => {
185+
it('an org-wide brownout no longer reports a clean bill of health', () => {
186+
// 100 of 100 members unverified: the exact shape that shipped green.
187+
expect(evaluateIntegrity(withUnverified(100, 100))).toBe('degraded');
188+
});
189+
190+
it('crosses to degraded at the 15% gate', () => {
191+
expect(evaluateIntegrity(withUnverified(100, 14))).toBe('ok');
192+
expect(evaluateIntegrity(withUnverified(100, 15))).toBe('degraded');
193+
});
194+
195+
it('NEVER aborts, however many members are unverified', () => {
196+
// They are present in the report — losing the whole report over an
197+
// unverified PR count would be worse than the doubt it signals.
198+
expect(evaluateIntegrity(withUnverified(100, 100))).not.toBe('failed');
199+
expect(evaluateIntegrity(withUnverified(10, 10))).not.toBe('failed');
200+
});
201+
202+
it('a handful of unverified members stays ok', () => {
203+
expect(evaluateIntegrity(withUnverified(100, 3))).toBe('ok');
204+
});
205+
206+
it('counts each login once even when several figures are unverified', () => {
207+
// merged-prs AND reviews for the same person is one affected member.
208+
const snap = {
209+
expectedCount: 10,
210+
thresholds: DEFAULT_THRESHOLDS,
211+
skipped: [] as never[],
212+
unverified: [
213+
{ login: 'a', field: 'merged-prs' as const, kept: 0, reason: 'x' },
214+
{ login: 'a', field: 'reviews' as const, kept: 0, reason: 'x' },
215+
],
216+
};
217+
expect(unverifiedCounts(snap).count).toBe(1);
218+
});
219+
220+
it('excludes allowlisted members from the denominator, like the skip gate', () => {
221+
const snap = {
222+
expectedCount: 100,
223+
skipped: many(40, 'expected').map(([login, classification]) => ({
224+
login, reason: 'ok', classification,
225+
})),
226+
unverified: Array.from({ length: 9 }, (_, i) => ({
227+
login: `u${i}`, field: 'merged-prs' as const, kept: 0, reason: 'x',
228+
})),
229+
};
230+
// 9 of 60 real members = 15%, not 9 of 100 = 9%.
231+
expect(unverifiedCounts(snap).pct).toBeCloseTo(0.15, 5);
232+
});
233+
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// @octokit/rest is ESM-only; FACTORY-form mock required before the import.
2+
jest.mock('@octokit/rest', () => ({ Octokit: jest.fn().mockImplementation(() => ({})) }));
3+
4+
import { fetchUserActivity, __setOctokitForTest } from '@/lib/github';
5+
import { formatIntegrityAbortReason, DEFAULT_THRESHOLDS } from '@/lib/report-runner/types';
6+
7+
/**
8+
* GLOOK-50 follow-up. The first fix made the merged-PR search raise on any
9+
* untrustworthy result. On 2026-09-09 that aborted the whole report at 21%:
10+
*
11+
* ABORT (GLOOK-13): 21 of 100 engineers couldn't be fetched (21%).
12+
* Most failures are GitHub search timeouts (21 of 21)
13+
*
14+
* ~19 of those 21 were false. Verified against GitHub: sl-chromatic-bot,
15+
* sl-data-team-jenkins, magdalenastaller, keser, flaksie, gkim-smartling and
16+
* viakivchuk-smartling all have GENUINELY zero merged PRs, and an empty issue
17+
* search routinely reports incomplete_results=true while being correct.
18+
*
19+
* Commit search is different and stays strict: there an empty timed-out page
20+
* really did hide 43 commits. The asymmetry is the point.
21+
*/
22+
23+
jest.useFakeTimers({ doNotFake: ['nextTick'], now: new Date('2026-09-09T12:00:00Z') });
24+
afterEach(() => { jest.clearAllTimers(); __setOctokitForTest(null); });
25+
26+
/**
27+
* Drain the 2.5s inter-page and 5s retry sleeps.
28+
*
29+
* Deliberately does NOT stop at the first moment `getTimerCount()` hits zero:
30+
* between two awaited sleeps there is a tick with no timer pending, and
31+
* breaking there leaves the promise waiting forever on a clock that has
32+
* stopped advancing.
33+
*/
34+
async function drain<T>(p: Promise<T>): Promise<T> {
35+
let settled = false;
36+
const tracked = p.then(
37+
(v) => { settled = true; return v; },
38+
(e) => { settled = true; throw e; },
39+
);
40+
tracked.catch(() => {}); // don't trip unhandled-rejection while we pump
41+
for (let i = 0; i < 200 && !settled; i++) {
42+
jest.advanceTimersByTime(10_000);
43+
for (let j = 0; j < 10; j++) await Promise.resolve();
44+
}
45+
return tracked;
46+
}
47+
48+
const SINCE = new Date('2026-08-26T00:00:00Z');
49+
const HEALTHY_EMPTY_COMMITS = { total_count: 0, items: [], incomplete_results: false };
50+
51+
/** Octokit stub: healthy commit search, scripted issue search. */
52+
function stub(issueData: any) {
53+
const calls = { commits: 0, issues: 0 };
54+
__setOctokitForTest({
55+
search: {
56+
commits: async () => { calls.commits++; return { data: HEALTHY_EMPTY_COMMITS }; },
57+
issuesAndPullRequests: async () => { calls.issues++; return { data: issueData }; },
58+
},
59+
});
60+
return calls;
61+
}
62+
63+
describe('an empty merged-PR search that timed out', () => {
64+
const TIMED_OUT_EMPTY = { total_count: 0, items: [], incomplete_results: true };
65+
66+
it('does NOT skip the member — it keeps the zero and records the doubt', async () => {
67+
stub(TIMED_OUT_EMPTY);
68+
const activity = await drain(fetchUserActivity('Smartling', 'sl-chromatic-bot', SINCE));
69+
expect(activity.prs).toEqual([]);
70+
expect(activity.prsUnverified).toMatch(/timed out on an empty result/);
71+
});
72+
73+
it('retries before giving up on it', async () => {
74+
const calls = stub(TIMED_OUT_EMPTY);
75+
await drain(fetchUserActivity('Smartling', 'keser', SINCE));
76+
// Exactly 3 attempts on the merged-PR page: 1 + SEARCH_TIMEOUT_RETRIES.
77+
expect(calls.issues).toBe(3);
78+
});
79+
80+
it('takes the good answer when a retry recovers', async () => {
81+
let n = 0;
82+
__setOctokitForTest({
83+
search: {
84+
commits: async () => ({ data: HEALTHY_EMPTY_COMMITS }),
85+
issuesAndPullRequests: async () => {
86+
n++;
87+
if (n === 1) return { data: TIMED_OUT_EMPTY };
88+
return {
89+
data: {
90+
total_count: 1, incomplete_results: false,
91+
items: [{
92+
number: 7, title: 'fix', repository_url: 'https://api.github.com/repos/Smartling/pinch',
93+
pull_request: { merged_at: '2026-09-01T00:00:00Z' },
94+
}],
95+
},
96+
};
97+
},
98+
},
99+
});
100+
const activity = await drain(fetchUserActivity('Smartling', 'devx', SINCE));
101+
expect(activity.prs).toHaveLength(1);
102+
expect(activity.prsUnverified).toBeUndefined();
103+
});
104+
});
105+
106+
describe('a self-contradicting merged-PR page still raises', () => {
107+
it('raises when GitHub counts PRs but delivers none', async () => {
108+
// The protection worth keeping: this is the shape that would land a
109+
// developer with 42 merged PRs in the report as 0. Observed in the same
110+
// run for ksoloviov-smartling (total_count=3) and kbroadrick (2).
111+
stub({ total_count: 3, items: [], incomplete_results: true });
112+
await expect(drain(fetchUserActivity('Smartling', 'ksoloviov-smartling', SINCE)))
113+
.rejects.toThrow(/no trustworthy result for @ksoloviov-smartling: counted 3 PRs but delivered none/);
114+
});
115+
});
116+
117+
describe('a healthy empty merged-PR search', () => {
118+
it('is trusted with no retry and no doubt recorded', async () => {
119+
const calls = stub({ total_count: 0, items: [], incomplete_results: false });
120+
const activity = await drain(fetchUserActivity('Smartling', 'flaksie', SINCE));
121+
expect(activity.prs).toEqual([]);
122+
expect(activity.prsUnverified).toBeUndefined();
123+
// One call only: fetchUserActivity does commits + merged PRs;
124+
// countReviewedPRs is invoked separately by report-runner.
125+
expect(calls.issues).toBe(1);
126+
});
127+
});
128+
129+
/**
130+
* The abort banner classifies a skip as a search brownout by pattern-matching
131+
* the skip reason. Nothing coupled the thrown messages to that matcher, so a
132+
* reworded raise silently sent the on-call to rotate the PAT during a GitHub
133+
* outage — which is the exact lead #70 added the attribution to prevent.
134+
*/
135+
describe('thrown search messages stay matched to the brownout attribution', () => {
136+
const attributable = (reason: string) => {
137+
const snap = {
138+
expectedCount: 100,
139+
thresholds: DEFAULT_THRESHOLDS,
140+
skipped: Array.from({ length: 6 }, (_, i) => ({
141+
login: `u${i}`, reason, classification: 'unknown' as const,
142+
})),
143+
};
144+
return formatIntegrityAbortReason(snap);
145+
};
146+
147+
it('attributes the contradictory merged-PR raise to a search brownout', () => {
148+
const msg = attributable(
149+
'GitHub merged-PR search gave no trustworthy result for @u: counted 3 PRs but delivered none (page 1)',
150+
);
151+
expect(msg).toMatch(/GitHub search timeouts/);
152+
expect(msg).not.toMatch(/auth\/permission/);
153+
});
154+
155+
it('attributes the mid-pagination merged-PR raise too', () => {
156+
const msg = attributable(
157+
'GitHub merged-PR search gave no trustworthy result for @u: under-delivered 100 of 250 (page 2)',
158+
);
159+
expect(msg).toMatch(/GitHub search timeouts/);
160+
});
161+
162+
it('attributes the commit-search raises', () => {
163+
expect(attributable('GitHub commit search gave no trustworthy result for @u (…) after 3 attempts'))
164+
.toMatch(/GitHub search timeouts/);
165+
expect(attributable('GitHub commit search under-delivered for @u (…): got 0 of 21'))
166+
.toMatch(/GitHub search timeouts/);
167+
});
168+
169+
it('still blames auth for a genuine permission failure', () => {
170+
expect(attributable('Validation Failed: the listed users cannot be searched'))
171+
.toMatch(/auth\/permission/);
172+
});
173+
});

‎src/lib/__tests__/unit/search-timeout-recovery.test.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,24 @@ jest.useFakeTimers({ doNotFake: ['nextTick'], now: new Date('2026-09-09T00:00:00
2222
afterEach(() => { jest.clearAllTimers(); __setOctokitForTest(null); });
2323

2424
/** Drain the 2.5s inter-page and 5s retry sleeps. */
25+
/**
26+
* Drain the 2.5s inter-page and 5s retry sleeps. Does NOT stop at the first
27+
* moment `getTimerCount()` hits zero — between two awaited sleeps there is a
28+
* tick with no timer pending, and breaking there strands the promise on a
29+
* clock that has stopped advancing.
30+
*/
2531
async function drain<T>(p: Promise<T>): Promise<T> {
26-
for (let i = 0; i < 40; i++) {
27-
for (let j = 0; j < 5; j++) await Promise.resolve();
28-
if (jest.getTimerCount() === 0) break;
32+
let settled = false;
33+
const tracked = p.then(
34+
(v) => { settled = true; return v; },
35+
(e) => { settled = true; throw e; },
36+
);
37+
tracked.catch(() => {});
38+
for (let i = 0; i < 200 && !settled; i++) {
2939
jest.advanceTimersByTime(10_000);
40+
for (let j = 0; j < 10; j++) await Promise.resolve();
3041
}
31-
return p;
42+
return tracked;
3243
}
3344

3445
const TIMED_OUT_EMPTY = { total_count: 0, items: [], incomplete_results: true };

‎src/lib/github-mock.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function createMockGitHubProvider(): GitHubProvider {
6969
},
7070

7171
async countReviewedPRs() {
72-
return Math.floor(Math.random() * 15);
72+
return { reviews: Math.floor(Math.random() * 15) };
7373
},
7474

7575
async fetchOpenPRs(_org, user, _since, log) {

0 commit comments

Comments
 (0)