Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function makeEnv(spec) {
failUnassignFor = [], // logins whose removeAssignees call should throw
failCommentOn = [], // issue/PR numbers whose createComment call should throw
failCloseFor = [], // PR numbers whose pulls.update (close) call should throw
reopenedAtByPr = {}, // PR number -> ISO date of its latest `reopened` event
} = spec;

const github = {
Expand All @@ -50,6 +51,8 @@ function makeEnv(spec) {
}
mut.unassigned.push({ issue_number, assignees });
},
listEvents: ({ issue_number }) =>
reopenedAtByPr[issue_number] ? [{ event: "reopened", created_at: reopenedAtByPr[issue_number] }] : [],
},
pulls: {
get: async ({ pull_number }) => {
Expand Down Expand Up @@ -610,6 +613,53 @@ describe("bot-contributor-lifecycle", () => {
expect(m.unassigned).toHaveLength(0);
});

test("a recently reopened stale PR is NOT re-closed — the clock restarts from the reopen", async () => {
// The maintainer-recovery flow: PR closed for staleness weeks ago, contributor is
// re-assigned and the PR reopened 2 days ago with no new commits yet. Must survive.
const spec = {
...specWithPR(143, 271, "alice", { author: "alice", reviews: [humanReview(70)], commitDate: daysAgo(80) }),
reopenedAtByPr: { 271: daysAgo(2) },
};
const m = await run(spec, { DRY_RUN: "false" });
expect(m.closed).toHaveLength(0);
expect(m.comments).toHaveLength(0);
expect(m.unassigned).toHaveLength(0);
});

test("a reopen also prevents a premature reminder (remind-window case)", async () => {
// Last review is inside the remind window (15d) but the PR was manually closed
// and reopened 2 days ago — the reopen is the newest activity, so no action.
const spec = {
...specWithPR(146, 274, "alice", { author: "alice", reviews: [humanReview(15)], commitDate: daysAgo(20) }),
reopenedAtByPr: { 274: daysAgo(2) },
};
const m = await run(spec, { DRY_RUN: "false" });
expect(m.closed).toHaveLength(0);
expect(m.comments).toHaveLength(0);
expect(m.unassigned).toHaveLength(0);
});

test("a reopened PR idle past the remind threshold gets a reminder, not a close", async () => {
const spec = {
...specWithPR(144, 272, "alice", { author: "alice", reviews: [humanReview(70)], commitDate: daysAgo(80) }),
reopenedAtByPr: { 272: daysAgo(15) },
};
const m = await run(spec, { DRY_RUN: "false" });
expect(m.closed).toHaveLength(0);
expect(commentedOn(m, 272, "<!-- pr-inactivity-bot-marker -->")).toBe(true);
expect(m.unassigned).toHaveLength(0);
});

test("a reopened PR that idles past the close threshold again IS closed", async () => {
const spec = {
...specWithPR(145, 273, "alice", { author: "alice", reviews: [humanReview(90)], commitDate: daysAgo(95) }),
reopenedAtByPr: { 273: daysAgo(70) },
};
const m = await run(spec, { DRY_RUN: "false" });
expect(m.closed.some((c) => c.pull_number === 273)).toBe(true);
expect(unassignedFrom(m, 145, "alice")).toBe(true);
});

test("writes a markdown stats table to GITHUB_STEP_SUMMARY when set", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lifecycle-summary-"));
const summaryFile = path.join(dir, "summary.md");
Expand Down
50 changes: 48 additions & 2 deletions .github/scripts/bot-contributor-lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ assignees (not PRs). For each issue it applies a staged escalation:
"age" is measured from the most recent activity, which always includes a recent
`/working` comment from an assignee — so `/working` resets every timer. PR age
also includes the last commit and the last human (non-bot, non-author) review.
Reopening a closed PR also counts as activity: the reopen timestamp restarts the
clock, so a deliberately revived PR gets a full fresh remind/close cycle instead
of being re-closed on the next run (works for PRs closed by the pre-consolidation
bots too, whose close comments carried no marker).

Markers are cycle-scoped: a bot marker comment only suppresses a repeat action if it
was posted AFTER the current cycle began (the assignee's latest assignment for issue
Expand Down Expand Up @@ -283,6 +287,33 @@ async function lastCommitDate(github, pr) {
}
}

// Most recent `reopened` event on the PR (Date or null). A reopen is a deliberate
// human signal — server-timestamped and works no matter who/what closed the PR
// (including the pre-consolidation bots, whose close comments carried no marker) —
// so it resets the inactivity clock and grants a full fresh remind/close cycle.
// Only called when a PR is old enough that the bot is about to act on it
// (remind or close), so the extra API call stays rare.
async function lastReopenedDate(github, owner, repo, prNumber) {
try {
const events = await github.paginate(github.rest.issues.listEvents, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
let latest = null;
for (const e of events) {
if (e?.event !== "reopened" || !e.created_at) continue;
const d = new Date(e.created_at);
if (!latest || d > latest) latest = d;
}
return latest;
} catch (err) {
console.log(` [WARN] Could not fetch events for PR #${prNumber}:`, err.message || err);
return null;
}
}

// ---- message builders ----

function issueReminderBody(assignees) {
Expand Down Expand Up @@ -496,10 +527,25 @@ async function handlePRStage(github, owner, repo, issue, graph, prNumbers, comme
continue;
}
const commitDate = await lastCommitDate(github, pr);
const activityAt = maxDate([reviewDate, commitDate, workingAt]);
const ageDays = daysSince(activityAt, nowMs);
let activityAt = maxDate([reviewDate, commitDate, workingAt]);
let ageDays = daysSince(activityAt, nowMs);
console.log(` [INFO] PR #${prNum} reviewed; last activity ~${ageDays}d ago`);

// Before reminding/closing, honour a deliberate reopen: if the PR was reopened
// after its last review/commit activity, the reopen timestamp becomes the
// activity baseline, so a revived PR gets a full new remind/close cycle instead
// of a premature reminder or an immediate re-close (a silent close/reopen war
// the human can't win). Checked whenever the bot is about to take ANY action.
const actionThreshold = Math.min(cfg.prRemindDays, cfg.prCloseDays);
if (ageDays >= actionThreshold) {
const reopenedAt = await lastReopenedDate(github, owner, repo, prNum);
if (reopenedAt && (!activityAt || reopenedAt > activityAt)) {
activityAt = maxDate([activityAt, reopenedAt]);
ageDays = daysSince(activityAt, nowMs);
console.log(` [INFO] PR #${prNum} was reopened ~${ageDays}d ago; clock restarted from the reopen`);
}
}

// Close takes priority over remind so an (unusual) close < remind config still closes.
if (ageDays >= cfg.prCloseDays) {
toClose.push({ prNum, ageDays, activityAt });
Expand Down
Loading