@@ -18,55 +18,119 @@ function pr(overrides: Partial<PullRequestRecord> & { number: number }): PullReq
1818}
1919
2020describe ( "selectRegateCandidates (#777 re-gate sweep selection)" , ( ) => {
21- it ( "drops PRs updated within the freshness window (recently gated by their webhook)" , ( ) => {
22- const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 1 ) } ) , pr ( { number : 2 , updatedAt : minutesAgo ( 120 ) } ) ] ;
23- const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
24- expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 ] ) ; // #1 updated 1m ago is inside the 2-min freshness window
25- } ) ;
21+ describe ( "don't-race-webhook freshness guard (GitHub updatedAt)" , ( ) => {
22+ it ( "drops PRs whose GitHub updatedAt is within the freshness window (a webhook is gating them)" , ( ) => {
23+ const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 1 ) } ) , pr ( { number : 2 , updatedAt : minutesAgo ( 120 ) } ) ] ;
24+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
25+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 ] ) ; // #1 updated 1m ago is inside the 2-min window
26+ } ) ;
2627
27- it ( "orders the stalest first and bounds to max (rate-aware)" , ( ) => {
28- const pulls = [
29- pr ( { number : 1 , updatedAt : minutesAgo ( 120 ) } ) ,
30- pr ( { number : 2 , updatedAt : minutesAgo ( 600 ) } ) ,
31- pr ( { number : 3 , updatedAt : minutesAgo ( 300 ) } ) ,
32- ] ;
33- const picked = selectRegateCandidates ( { pulls, now : NOW , max : 2 } ) ;
34- expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 3 ] ) ; // stalest (600m), then 300m; 120m dropped by cap
28+ it ( "treats a missing updatedAt as NOT recently touched (eligible, never starved by the freshness guard)" , ( ) => {
29+ const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 1 ) } ) , pr ( { number : 2 } ) ] ;
30+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
31+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 ] ) ; // #1 fresh → dropped; #2 has no updatedAt → eligible
32+ } ) ;
33+
34+ it ( "keeps a PR whose lastRegatedAt is old but whose updatedAt is fresh OUT (the guard wins over the sort key)" , ( ) => {
35+ const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 1 ) , lastRegatedAt : minutesAgo ( 999 ) } ) ] ;
36+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
37+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ ] ) ; // stalest by re-gate, but a webhook just touched it → skip
38+ } ) ;
39+
40+ it ( "live case: when updatedAt and lastRegatedAt move together, the PR is eligible once outside the window (not double-excluded)" , ( ) => {
41+ const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 120 ) , lastRegatedAt : minutesAgo ( 120 ) } ) ] ;
42+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
43+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 1 ] ) ; // both old → freshness allows it, re-gate orders it
44+ } ) ;
45+
46+ it ( "keeps every open non-draft PR when `now` is unparseable (no freshness cutoff possible)" , ( ) => {
47+ const pulls = [ pr ( { number : 1 , createdAt : minutesAgo ( 5 ) } ) , pr ( { number : 2 , createdAt : minutesAgo ( 600 ) } ) , pr ( { number : 3 , isDraft : true } ) ] ;
48+ const picked = selectRegateCandidates ( { pulls, now : "not-a-date" , freshnessWindowMs : 30 * 60 * 1000 } ) ;
49+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 1 ] ) ; // drafts still excluded; both non-draft kept, stalest-created first
50+ } ) ;
3551 } ) ;
3652
37- it ( "treats a missing updatedAt as maximally stale and never starves it" , ( ) => {
38- const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 120 ) } ) , pr ( { number : 2 } ) ] ;
39- const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
40- expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 1 ] ) ; // no-timestamp PR sorts oldest
53+ describe ( "convergence sort key (lastRegatedAt, NOT GitHub updatedAt)" , ( ) => {
54+ it ( "INVARIANT arm (i): orders by lastRegatedAt ascending when present — the staler RE-GATE sorts first" , ( ) => {
55+ // #1 was re-gated recently but created long ago; #2 was re-gated long ago but created recently. The re-gate
56+ // marker — not createdAt — drives the order, so #2 (stalest re-gate) comes first.
57+ const pulls = [
58+ pr ( { number : 1 , lastRegatedAt : minutesAgo ( 10 ) , createdAt : minutesAgo ( 1000 ) } ) ,
59+ pr ( { number : 2 , lastRegatedAt : minutesAgo ( 100 ) , createdAt : minutesAgo ( 1 ) } ) ,
60+ ] ;
61+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
62+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 1 ] ) ;
63+ } ) ;
64+
65+ it ( "INVARIANT arm (ii): falls back to createdAt when lastRegatedAt is absent — oldest-created sorts first" , ( ) => {
66+ const pulls = [ pr ( { number : 1 , createdAt : minutesAgo ( 10 ) } ) , pr ( { number : 2 , createdAt : minutesAgo ( 600 ) } ) ] ;
67+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
68+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 1 ] ) ; // no lastRegatedAt on either → createdAt orders them
69+ } ) ;
70+
71+ it ( "INVARIANT arm (iii): falls back to the epoch when both lastRegatedAt and createdAt are absent — tie broken by PR number" , ( ) => {
72+ const pulls = [ pr ( { number : 9 } ) , pr ( { number : 4 } ) , pr ( { number : 7 } ) ] ;
73+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
74+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 4 , 7 , 9 ] ) ; // all epoch → deterministic number order
75+ } ) ;
76+
77+ it ( "a never-regated PR (lastRegatedAt absent) outranks a just-regated one — the property that makes the sweep converge" , ( ) => {
78+ const pulls = [
79+ pr ( { number : 1 , lastRegatedAt : minutesAgo ( 1 ) , createdAt : minutesAgo ( 1000 ) } ) , // just re-gated → freshest
80+ pr ( { number : 2 , createdAt : minutesAgo ( 50 ) } ) , // never re-gated → its createdAt (50m) is staler than #1's re-gate (1m)
81+ ] ;
82+ const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
83+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 1 ] ) ;
84+ } ) ;
85+
86+ it ( "bounds the batch to max (rate-aware) after ordering by re-gate staleness" , ( ) => {
87+ const pulls = [
88+ pr ( { number : 1 , lastRegatedAt : minutesAgo ( 120 ) } ) ,
89+ pr ( { number : 2 , lastRegatedAt : minutesAgo ( 600 ) } ) ,
90+ pr ( { number : 3 , lastRegatedAt : minutesAgo ( 300 ) } ) ,
91+ ] ;
92+ const picked = selectRegateCandidates ( { pulls, now : NOW , max : 2 } ) ;
93+ expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 3 ] ) ; // stalest re-gate (600m), then 300m; 120m dropped by cap
94+ } ) ;
4195 } ) ;
4296
4397 it ( "excludes drafts and non-open PRs" , ( ) => {
4498 const pulls = [
45- pr ( { number : 1 , updatedAt : minutesAgo ( 120 ) , isDraft : true } ) ,
46- pr ( { number : 2 , updatedAt : minutesAgo ( 120 ) , state : "closed" } ) ,
47- pr ( { number : 3 , updatedAt : minutesAgo ( 120 ) } ) ,
99+ pr ( { number : 1 , createdAt : minutesAgo ( 120 ) , isDraft : true } ) ,
100+ pr ( { number : 2 , createdAt : minutesAgo ( 120 ) , state : "closed" } ) ,
101+ pr ( { number : 3 , createdAt : minutesAgo ( 120 ) } ) ,
48102 ] ;
49103 const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
50104 expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 3 ] ) ;
51105 } ) ;
52106
53- it ( "is deterministic: equal staleness breaks ties by PR number" , ( ) => {
54- const ts = minutesAgo ( 200 ) ;
55- const pulls = [ pr ( { number : 9 , updatedAt : ts } ) , pr ( { number : 4 , updatedAt : ts } ) , pr ( { number : 7 , updatedAt : ts } ) ] ;
56- const picked = selectRegateCandidates ( { pulls, now : NOW } ) ;
57- expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 4 , 7 , 9 ] ) ;
58- } ) ;
59-
60- it ( "keeps every open non-draft PR when `now` is unparseable (no freshness cutoff possible)" , ( ) => {
61- const pulls = [ pr ( { number : 1 , updatedAt : minutesAgo ( 5 ) } ) , pr ( { number : 2 , updatedAt : minutesAgo ( 600 ) } ) , pr ( { number : 3 , isDraft : true } ) ] ;
62- const picked = selectRegateCandidates ( { pulls, now : "not-a-date" , freshnessWindowMs : 30 * 60 * 1000 } ) ;
63- expect ( picked . map ( ( p ) => p . number ) ) . toEqual ( [ 2 , 1 ] ) ; // drafts still excluded; both non-draft kept, stalest first
107+ it ( "REGRESSION (convergence): ceil(50/25)=2 sweeps with all GitHub writes suppressed cover ALL 50 open PRs, none re-selected before the rest are stamped" , ( ) => {
108+ // Simulate the dry-run / paused world: a re-gate stamps lastRegatedAt (a D1 write, never suppressed) but the
109+ // GitHub updatedAt is frozen. Without the fix the same 25 stalest would recur every sweep forever; with it,
110+ // two sweeps of 25 (the cap) cover all 50 distinct PRs exactly once — full coverage in ceil(open/max) sweeps.
111+ const pulls = Array . from ( { length : 50 } , ( _ , i ) => pr ( { number : i + 1 , createdAt : minutesAgo ( 1000 - i ) , updatedAt : minutesAgo ( 1000 ) } ) ) ;
112+ const stampedAt = new Map < number , string > ( ) ;
113+ const covered = new Set < number > ( ) ;
114+ let sweepNow = nowMs ;
115+ for ( let sweep = 0 ; sweep < 2 ; sweep ++ ) {
116+ sweepNow += 5 * 60 * 1000 ; // each sweep runs ~5 min later (outside the freshness window)
117+ const now = new Date ( sweepNow ) . toISOString ( ) ;
118+ const view = pulls . map ( ( p ) => ( { ...p , lastRegatedAt : stampedAt . get ( p . number ) ?? p . lastRegatedAt } ) ) ;
119+ const picked = selectRegateCandidates ( { pulls : view , now } ) ;
120+ expect ( picked . length ) . toBe ( SWEEP_MAX_PRS ) ; // each sweep fills the cap until the queue is drained
121+ for ( const p of picked ) {
122+ expect ( covered . has ( p . number ) ) . toBe ( false ) ; // never re-selected before all are stamped
123+ covered . add ( p . number ) ;
124+ stampedAt . set ( p . number , now ) ; // the sweep stamps lastRegatedAt = now
125+ }
126+ }
127+ expect ( covered . size ) . toBe ( 50 ) ; // full coverage of every open PR
64128 } ) ;
65129
66130 it ( "defaults: freshness window is two minutes and the cap is 25" , ( ) => {
67131 expect ( SWEEP_FRESHNESS_MS ) . toBe ( 2 * 60 * 1000 ) ;
68132 expect ( SWEEP_MAX_PRS ) . toBe ( 25 ) ;
69- const pulls = Array . from ( { length : 40 } , ( _ , i ) => pr ( { number : i + 1 , updatedAt : minutesAgo ( 120 + i ) } ) ) ;
133+ const pulls = Array . from ( { length : 40 } , ( _ , i ) => pr ( { number : i + 1 , createdAt : minutesAgo ( 120 + i ) } ) ) ;
70134 expect ( selectRegateCandidates ( { pulls, now : NOW } ) ) . toHaveLength ( 25 ) ;
71135 } ) ;
72136} ) ;
0 commit comments