PR Reminder Bot #311
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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."); |