Skip to content

PR Reminder Bot

PR Reminder Bot #313

Workflow file for this run

name: PR Reminder Bot
on:
schedule:
# Argentina UTC-3
# Ventana local 08:00-20:00 => UTC 11:00-23:00
- cron: "0 11,14,17,20,23 * * *"
workflow_dispatch:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review
- review_requested
- review_request_removed
- labeled
- unlabeled
- converted_to_draft
jobs:
remind:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
contents: read
env:
TARGET_LABEL: "ready-for-review"
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Run PR Reminder
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const now = new Date();
const TARGET_LABEL = process.env.TARGET_LABEL;
const MVRU_LOGIN = "mvru";
function lower(s) {
return String(s || "").toLowerCase();
}
function hoursBetween(a, b) {
return (a - b) / (1000 * 60 * 60);
}
function latestDate(dates) {
const valid = dates.filter(Boolean).map(d => new Date(d));
if (valid.length === 0) return null;
return new Date(Math.max(...valid.map(d => d.getTime())));
}
function getLatestReviewByUser(reviews) {
const latest = {};
for (const review of reviews) {
if (!review.user) continue;
const login = lower(review.user.login);
const submittedAt = new Date(review.submitted_at || 0);
if (!latest[login] || new Date(latest[login].submitted_at || 0) < submittedAt) {
latest[login] = review;
}
}
return latest;
}
function getStage(hoursSinceReady) {
if (hoursSinceReady >= 72) return "72h";
if (hoursSinceReady >= 48) return "48h";
if (hoursSinceReady >= 24) return "24h";
return null;
}
function extractBotStages(comments) {
const stages = new Set();
for (const c of comments) {
if (c.user?.type !== "Bot") continue;
const body = c.body || "";
const match = body.match(/<!--\s*pr-reminder:stage=([a-z0-9_-]+)\s*-->/i);
if (match?.[1]) {
stages.add(match[1]);
}
}
return stages;
}
async function getTimelineEvents(issue_number) {
return await github.paginate(
github.rest.issues.listEventsForTimeline,
{
owner,
repo,
issue_number,
per_page: 100
}
);
}
async function getReviewComments(pull_number) {
return await github.paginate(
github.rest.pulls.listReviewComments,
{
owner,
repo,
pull_number,
per_page: 100
}
);
}
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100
});
for (const pr of prs) {
if (!pr || pr.user?.type === "Bot") continue;
if (pr.state !== "open" || pr.merged) continue;
if (pr.draft) continue;
const prNumber = pr.number;
const author = lower(pr.user.login);
const labels = (pr.labels || []).map(l => l.name);
const labelsLower = labels.map(lower);
// Solo entra al flujo si realmente está marcado ready-for-review
if (!labelsLower.includes(lower(TARGET_LABEL))) {
continue;
}
const [
reviews,
issueComments,
timelineEvents,
reviewComments
] = await Promise.all([
github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: prNumber,
per_page: 100
}),
github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100
}),
getTimelineEvents(prNumber),
getReviewComments(prNumber)
]);
const latestReviewByUser = getLatestReviewByUser(reviews);
const reviewersWhoCompleted = Object.values(latestReviewByUser)
.filter(r => ["COMMENTED", "APPROVED", "CHANGES_REQUESTED"].includes(r.state))
.map(r => lower(r.user.login));
const hasBlockingReview = Object.values(latestReviewByUser)
.some(r => r.state === "CHANGES_REQUESTED");
const requestedReviewers = (pr.requested_reviewers || []).map(r => r.login);
const pendingReviewers = requestedReviewers
.filter(login => !reviewersWhoCompleted.includes(lower(login)));
if (pendingReviewers.length === 0) {
continue;
}
const pendingReviewersLower = pendingReviewers.map(lower);
const reviewersStr = pendingReviewers.map(r => `@${r}`).join(" ");
// Baseline real:
// - fecha del label ready-for-review
// - fecha del evento ready_for_review
// usamos la más tardía de ambas
const readyLabelEvents = timelineEvents.filter(e =>
e.event === "labeled" &&
lower(e.label?.name) === lower(TARGET_LABEL)
);
const readyForReviewEvents = timelineEvents.filter(e =>
e.event === "ready_for_review"
);
const readyAt = latestDate([
...readyLabelEvents.map(e => e.created_at),
...readyForReviewEvents.map(e => e.created_at)
]);
if (!readyAt) {
continue;
}
const hoursSinceReady = hoursBetween(now, readyAt);
const currentStage = getStage(hoursSinceReady);
if (!currentStage) {
continue;
}
// Pronunciamiento = review, issue comment o review comment
const reviewerLastActivity = {};
for (const reviewer of pendingReviewersLower) {
reviewerLastActivity[reviewer] = null;
}
for (const review of reviews) {
if (!review.user) continue;
const login = lower(review.user.login);
if (!pendingReviewersLower.includes(login)) continue;
if (!["COMMENTED", "APPROVED", "CHANGES_REQUESTED"].includes(review.state)) continue;
const dt = new Date(review.submitted_at || 0);
if (!reviewerLastActivity[login] || reviewerLastActivity[login] < dt) {
reviewerLastActivity[login] = dt;
}
}
for (const comment of issueComments) {
if (!comment.user) continue;
const login = lower(comment.user.login);
if (!pendingReviewersLower.includes(login)) continue;
const dt = new Date(comment.created_at || 0);
if (!reviewerLastActivity[login] || reviewerLastActivity[login] < dt) {
reviewerLastActivity[login] = dt;
}
}
for (const comment of reviewComments) {
if (!comment.user) continue;
const login = lower(comment.user.login);
if (!pendingReviewersLower.includes(login)) continue;
const dt = new Date(comment.created_at || 0);
if (!reviewerLastActivity[login] || reviewerLastActivity[login] < dt) {
reviewerLastActivity[login] = dt;
}
}
const silentReviewers = pendingReviewers.filter(
reviewer => !reviewerLastActivity[lower(reviewer)]
);
const silentReviewersStr = silentReviewers.map(r => `@${r}`).join(" ");
const botStages = extractBotStages(issueComments);
const mvruLatestReview = latestReviewByUser[MVRU_LOGIN];
const isAuthorMVRU = author === MVRU_LOGIN;
const hasMvruApproved = mvruLatestReview?.state === "APPROVED";
const exactlyOnePending = pendingReviewers.length === 1;
const exactlyOneSilent = silentReviewers.length === 1;
const lonePendingIsNotMvru =
exactlyOnePending && lower(pendingReviewers[0]) !== MVRU_LOGIN;
const canAnnounceForcedMerge =
currentStage === "72h" &&
!hasBlockingReview &&
exactlyOnePending &&
exactlyOneSilent &&
lonePendingIsNotMvru &&
(isAuthorMVRU || hasMvruApproved);
// Evitar repetir exactamente la misma etapa
if (currentStage && botStages.has(currentStage)) {
continue;
}
// Si hay freeze y ya se comentó freeze, no repetirlo solo por freeze
const sections = [];
if (currentStage === "24h") {
sections.push([
"## ⏰ Recordatorio de revisión — 24h",
"",
"Este PR quedó listo para revisión hace más de **24 horas**.",
"",
`Pendientes: ${reviewersStr}`,
"",
"Se solicita al menos un pronunciamiento:",
"- comentario",
"- aprobación",
"- solicitud de cambios"
].join("\n"));
}
if (currentStage === "48h") {
sections.push([
"## ⚠️ Escalada de revisión — 48h",
"",
"Este PR quedó listo para revisión hace más de **48 horas**.",
"",
`Pendientes: ${reviewersStr}`,
silentReviewers.length > 0 ? `Sin pronunciamiento aún: ${silentReviewersStr}` : "",
"",
"Ya pasó una ventana razonable de revisión. Se requiere respuesta."
].filter(Boolean).join("\n"));
}
if (currentStage === "72h") {
if (canAnnounceForcedMerge) {
sections.push([
"## 🚨 Escalada máxima — 72h+",
"",
"Este PR quedó listo para revisión hace más de **72 horas**.",
"",
`Pendiente sin pronunciamiento: ${silentReviewersStr}`,
"",
"Dado que **solo falta 1 reviewer**, no hubo respuesta y no hay bloqueo activo por cambios solicitados,",
"**queda habilitado el merge forzado a `main` por falta de respuesta**."
].join("\n"));
} else {
sections.push([
"## 🚨 Escalada de revisión — 72h+",
"",
"Este PR quedó listo para revisión hace más de **72 horas**.",
"",
`Pendientes: ${reviewersStr}`,
silentReviewers.length > 0 ? `Sin pronunciamiento aún: ${silentReviewersStr}` : "",
"",
hasBlockingReview
? "Existe un `CHANGES_REQUESTED`, por lo tanto no corresponde hablar de merge forzado."
: "Se requiere resolución prioritaria."
].filter(Boolean).join("\n"));
}
}
if (sections.length === 0) {
continue;
}
if (
(currentStage === "48h" || currentStage === "72h") &&
!labelsLower.includes("needs-focus")
) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: ["needs-focus"]
});
} catch (e) {
console.error(`Error agregando label needs-focus en PR #${prNumber}: ${e.message}`);
}
}
const markers = [];
if (currentStage) {
markers.push(`<!-- pr-reminder:stage=${currentStage} -->`);
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: [
...markers,
...sections
].join("\n\n")
});
}
console.log("Finalizado.");