@@ -29,6 +29,7 @@ function makeEnv(spec) {
2929 failUnassignFor = [ ] , // logins whose removeAssignees call should throw
3030 failCommentOn = [ ] , // issue/PR numbers whose createComment call should throw
3131 failCloseFor = [ ] , // PR numbers whose pulls.update (close) call should throw
32+ reopenedAtByPr = { } , // PR number -> ISO date of its latest `reopened` event
3233 } = spec ;
3334
3435 const github = {
@@ -50,6 +51,8 @@ function makeEnv(spec) {
5051 }
5152 mut . unassigned . push ( { issue_number, assignees } ) ;
5253 } ,
54+ listEvents : ( { issue_number } ) =>
55+ reopenedAtByPr [ issue_number ] ? [ { event : "reopened" , created_at : reopenedAtByPr [ issue_number ] } ] : [ ] ,
5356 } ,
5457 pulls : {
5558 get : async ( { pull_number } ) => {
@@ -610,6 +613,53 @@ describe("bot-contributor-lifecycle", () => {
610613 expect ( m . unassigned ) . toHaveLength ( 0 ) ;
611614 } ) ;
612615
616+ test ( "a recently reopened stale PR is NOT re-closed — the clock restarts from the reopen" , async ( ) => {
617+ // The maintainer-recovery flow: PR closed for staleness weeks ago, contributor is
618+ // re-assigned and the PR reopened 2 days ago with no new commits yet. Must survive.
619+ const spec = {
620+ ...specWithPR ( 143 , 271 , "alice" , { author : "alice" , reviews : [ humanReview ( 70 ) ] , commitDate : daysAgo ( 80 ) } ) ,
621+ reopenedAtByPr : { 271 : daysAgo ( 2 ) } ,
622+ } ;
623+ const m = await run ( spec , { DRY_RUN : "false" } ) ;
624+ expect ( m . closed ) . toHaveLength ( 0 ) ;
625+ expect ( m . comments ) . toHaveLength ( 0 ) ;
626+ expect ( m . unassigned ) . toHaveLength ( 0 ) ;
627+ } ) ;
628+
629+ test ( "a reopen also prevents a premature reminder (remind-window case)" , async ( ) => {
630+ // Last review is inside the remind window (15d) but the PR was manually closed
631+ // and reopened 2 days ago — the reopen is the newest activity, so no action.
632+ const spec = {
633+ ...specWithPR ( 146 , 274 , "alice" , { author : "alice" , reviews : [ humanReview ( 15 ) ] , commitDate : daysAgo ( 20 ) } ) ,
634+ reopenedAtByPr : { 274 : daysAgo ( 2 ) } ,
635+ } ;
636+ const m = await run ( spec , { DRY_RUN : "false" } ) ;
637+ expect ( m . closed ) . toHaveLength ( 0 ) ;
638+ expect ( m . comments ) . toHaveLength ( 0 ) ;
639+ expect ( m . unassigned ) . toHaveLength ( 0 ) ;
640+ } ) ;
641+
642+ test ( "a reopened PR idle past the remind threshold gets a reminder, not a close" , async ( ) => {
643+ const spec = {
644+ ...specWithPR ( 144 , 272 , "alice" , { author : "alice" , reviews : [ humanReview ( 70 ) ] , commitDate : daysAgo ( 80 ) } ) ,
645+ reopenedAtByPr : { 272 : daysAgo ( 15 ) } ,
646+ } ;
647+ const m = await run ( spec , { DRY_RUN : "false" } ) ;
648+ expect ( m . closed ) . toHaveLength ( 0 ) ;
649+ expect ( commentedOn ( m , 272 , "<!-- pr-inactivity-bot-marker -->" ) ) . toBe ( true ) ;
650+ expect ( m . unassigned ) . toHaveLength ( 0 ) ;
651+ } ) ;
652+
653+ test ( "a reopened PR that idles past the close threshold again IS closed" , async ( ) => {
654+ const spec = {
655+ ...specWithPR ( 145 , 273 , "alice" , { author : "alice" , reviews : [ humanReview ( 90 ) ] , commitDate : daysAgo ( 95 ) } ) ,
656+ reopenedAtByPr : { 273 : daysAgo ( 70 ) } ,
657+ } ;
658+ const m = await run ( spec , { DRY_RUN : "false" } ) ;
659+ expect ( m . closed . some ( ( c ) => c . pull_number === 273 ) ) . toBe ( true ) ;
660+ expect ( unassignedFrom ( m , 145 , "alice" ) ) . toBe ( true ) ;
661+ } ) ;
662+
613663 test ( "writes a markdown stats table to GITHUB_STEP_SUMMARY when set" , async ( ) => {
614664 const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "lifecycle-summary-" ) ) ;
615665 const summaryFile = path . join ( dir , "summary.md" ) ;
0 commit comments