diff --git a/.github/scripts/docs-review-digest.js b/.github/scripts/docs-review-digest.js new file mode 100644 index 000000000..b896b228c --- /dev/null +++ b/.github/scripts/docs-review-digest.js @@ -0,0 +1,299 @@ +#!/usr/bin/env node + +const REQUIRED_ENV = ["GITHUB_TOKEN", "GITHUB_REPOSITORY"]; + +const READY_LABEL = process.env.READY_LABEL || "ready for review"; +const REVIEWED_LABEL = process.env.REVIEWED_LABEL || "✅ REVIEWED ✅"; +const CHANGES_NEEDED_LABEL = process.env.CHANGES_NEEDED_LABEL || "changes needed"; +const REVIEW_ACTIVITY_WINDOW_HOURS = Number(process.env.REVIEW_ACTIVITY_WINDOW_HOURS || "48"); + +function requireEnv() { + for (const key of REQUIRED_ENV) { + if (!process.env[key]) { + console.error(`Missing required env var: ${key}`); + process.exit(1); + } + } +} + +function daysBetween(startDate, endDate) { + const msPerDay = 24 * 60 * 60 * 1000; + return Math.floor((endDate.getTime() - startDate.getTime()) / msPerDay); +} + +function hoursBetween(startDate, endDate) { + const msPerHour = 60 * 60 * 1000; + return Math.floor((endDate.getTime() - startDate.getTime()) / msPerHour); +} + +function hasLabel(pr, labelName) { + return pr.labels.nodes.some((label) => label.name === labelName); +} + +function latestReviewDate(pr) { + const reviewDates = pr.reviews.nodes.map((review) => new Date(review.submittedAt)); + const commentDates = pr.reviewThreads.nodes.flatMap((thread) => + thread.comments.nodes.map((comment) => new Date(comment.createdAt)) + ); + + const allDates = [...reviewDates, ...commentDates].filter((date) => !Number.isNaN(date.getTime())); + + if (allDates.length === 0) { + return null; + } + + return new Date(Math.max(...allDates.map((date) => date.getTime()))); +} + +function hasApproval(pr) { + return pr.reviews.nodes.some((review) => review.state === "APPROVED"); +} + +function bucketForAge(daysReady) { + if (daysReady >= 14) { + return "very_stale"; + } + + if (daysReady >= 7) { + return "stale"; + } + + if (daysReady >= 5) { + return "needs_attention"; + } + + if (daysReady >= 3) { + return "coming_up"; + } + + return null; +} + +async function githubGraphql(query, variables) { + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + "Content-Type": "application/json", + "User-Agent": "docs-review-digest" + }, + body: JSON.stringify({ query, variables }) + }); + + const body = await response.json(); + + if (!response.ok || body.errors) { + throw new Error(`GitHub GraphQL query failed: ${JSON.stringify(body, null, 2)}`); + } + + return body.data; +} + +async function fetchOpenPullRequests(owner, repo) { + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests( + states: OPEN + first: 50 + after: $cursor + orderBy: { field: CREATED_AT, direction: DESC } + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + url + author { + login + } + createdAt + isDraft + labels(first: 20) { + nodes { + name + } + } + reviewRequests(first: 20) { + nodes { + requestedReviewer { + ... on User { + login + } + ... on Team { + name + } + } + } + } + reviews(first: 50, states: [APPROVED, CHANGES_REQUESTED, COMMENTED], author: null) { + nodes { + state + submittedAt + author { + login + } + } + } + reviewThreads(first: 50) { + nodes { + comments(first: 20) { + nodes { + createdAt + author { + login + } + } + } + } + } + timelineItems(first: 100, itemTypes: [LABELED_EVENT]) { + nodes { + ... on LabeledEvent { + createdAt + label { + name + } + } + } + } + } + } + } + } + `; + + const prs = []; + let cursor = null; + + do { + const data = await githubGraphql(query, { owner, repo, cursor }); + const page = data.repository.pullRequests; + + prs.push(...page.nodes); + + cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; + } while (cursor); + + return prs; +} + +function getReadyForReviewDate(pr) { + const readyLabelEvents = pr.timelineItems.nodes + .filter((item) => item.label?.name === READY_LABEL) + .map((item) => new Date(item.createdAt)) + .filter((date) => !Number.isNaN(date.getTime())); + + if (readyLabelEvents.length === 0) { + return new Date(pr.createdAt); + } + + return new Date(Math.max(...readyLabelEvents.map((date) => date.getTime()))); +} + +function mapReviewerNames(pr) { + const reviewers = pr.reviewRequests.nodes + .map((request) => request.requestedReviewer?.login || request.requestedReviewer?.name) + .filter(Boolean); + + return reviewers.length > 0 ? reviewers : ["Unassigned"]; +} + +function buildDigest(prs) { + const now = new Date(); + + const digest = { + source: "github", + repository: process.env.GITHUB_REPOSITORY, + queried_at: now.toISOString(), + criteria: { + ready_label: READY_LABEL, + reviewed_label: REVIEWED_LABEL, + changes_needed_label: CHANGES_NEEDED_LABEL, + review_activity_window_hours: REVIEW_ACTIVITY_WINDOW_HOURS + }, + total_count: 0, + buckets: { + coming_up: [], + needs_attention: [], + stale: [], + very_stale: [] + } + }; + + for (const pr of prs) { + if (pr.isDraft) { + continue; + } + + if (!hasLabel(pr, READY_LABEL)) { + continue; + } + + if (hasLabel(pr, REVIEWED_LABEL)) { + continue; + } + + if (hasApproval(pr)) { + continue; + } + + const latestReview = latestReviewDate(pr); + if (latestReview) { + const hoursSinceReview = hoursBetween(latestReview, now); + + if (hoursSinceReview < REVIEW_ACTIVITY_WINDOW_HOURS) { + continue; + } + } + + const readyForReviewAt = getReadyForReviewDate(pr); + const daysReady = daysBetween(readyForReviewAt, now); + const bucket = bucketForAge(daysReady); + + if (!bucket) { + continue; + } + + const item = { + number: pr.number, + title: pr.title, + url: pr.url, + author: pr.author?.login || "unknown", + reviewers: mapReviewerNames(pr), + ready_for_review_at: readyForReviewAt.toISOString(), + days_ready: daysReady, + latest_review_activity_at: latestReview ? latestReview.toISOString() : null, + labels: pr.labels.nodes.map((label) => label.name) + }; + + digest.buckets[bucket].push(item); + digest.total_count += 1; + } + + return digest; +} + +async function main() { + requireEnv(); + + const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + + if (!owner || !repo) { + throw new Error(`Invalid GITHUB_REPOSITORY value: ${process.env.GITHUB_REPOSITORY}`); + } + + const prs = await fetchOpenPullRequests(owner, repo); + const digest = buildDigest(prs); + + console.log(JSON.stringify(digest, null, 2)); +} + +main().catch((error) => { + console.error("Failed to build docs review digest."); + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/.github/scripts/render-docs-review-digest.js b/.github/scripts/render-docs-review-digest.js new file mode 100644 index 000000000..605526199 --- /dev/null +++ b/.github/scripts/render-docs-review-digest.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +const fs = require("fs"); + +const inputFile = process.env.DIGEST_FILE; +const outputFile = process.env.SLACK_MESSAGE_FILE; + +if (!inputFile) { + console.error("Missing DIGEST_FILE."); + process.exit(1); +} + +if (!outputFile) { + console.error("Missing SLACK_MESSAGE_FILE."); + process.exit(1); +} + +const digest = JSON.parse(fs.readFileSync(inputFile, "utf8")); + +const bucketLabels = [ + ["needs_attention", "Needs attention, 5-6 days"], + ["stale", "Stale, 7-13 days"], + ["very_stale", "Very stale, 14+ days"], + ["coming_up", "Coming up, 3-4 days"], +]; + +function pluralize(count, singular, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + +function formatReviewers(reviewers) { + if (!reviewers || reviewers.length === 0) { + return "No requested reviewers"; + } + + return reviewers.join(", "); +} + +function formatPr(pr) { + const latestActivity = pr.latest_review_activity_at + ? `Latest review activity: ${pr.latest_review_activity_at}` + : "No recorded review activity"; + + return [ + `• <${pr.url}|#${pr.number} ${pr.title}>`, + ` Author: ${pr.author}`, + ` Reviewers: ${formatReviewers(pr.reviewers)}`, + ` Ready for review: ${pr.days_ready} ${pluralize(pr.days_ready, "day")}`, + ` ${latestActivity}`, + ].join("\n"); +} + +const lines = []; + +lines.push("🔔 *Dinner Bell: Docs PRs waiting for review*"); +lines.push(""); +lines.push( + "These PRs are labeled `ready for review` and do not appear to have approval or recent review activity." +); +lines.push(""); +lines.push( + "Please review, approve, or comment with blockers. If these PRs should not move forward, please say so in the PR so docs can close the loop." +); +lines.push(""); +lines.push(`Total waiting: ${digest.total_count}`); + +for (const [bucketKey, bucketTitle] of bucketLabels) { + const prs = digest.buckets[bucketKey] || []; + + if (prs.length === 0) { + continue; + } + + lines.push(""); + lines.push(`*${bucketTitle}*`); + lines.push(""); + + for (const pr of prs) { + lines.push(formatPr(pr)); + lines.push(""); + } +} + +if (digest.total_count === 0) { + lines.push(""); + lines.push("No docs PRs need review right now."); +} + +fs.writeFileSync(outputFile, `${lines.join("\n").trim()}\n`); +console.log(`Wrote Slack message to ${outputFile}`); \ No newline at end of file diff --git a/.github/workflows/status-digest.yaml b/.github/workflows/status-digest.yaml new file mode 100644 index 000000000..c3c81d326 --- /dev/null +++ b/.github/workflows/status-digest.yaml @@ -0,0 +1,99 @@ +name: Dinner Bell + +on: + workflow_dispatch: + inputs: + post_to_slack: + description: "Post the digest to Slack" + required: false + default: "false" + type: choice + options: + - "true" + - "false" + + pull_request: + paths: + - ".github/workflows/status-digest.yaml" + - ".github/scripts/docs-review-digest.js" + - ".github/scripts/render-docs-review-digest.js" + + # schedule: + # # 10:00 AM Eastern during daylight saving time. + # # GitHub cron uses UTC. + # - cron: "0 14 * * 1-5" + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + docs-review-digest: + runs-on: ubuntu-latest + + steps: + - name: Check out docs repo + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Build docs review digest + run: node .github/scripts/docs-review-digest.js > ${{ runner.temp }}/docs-review-digest.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + READY_LABEL: "ready for review" + REVIEWED_LABEL: "✅ REVIEWED ✅" + CHANGES_NEEDED_LABEL: "changes needed" + REVIEW_ACTIVITY_WINDOW_HOURS: "48" + + - name: Show digest summary + run: | + echo "Digest file created:" + ls -lh ${{ runner.temp }}/docs-review-digest.json + echo "Digest summary:" + node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync(process.env.DIGEST_FILE,'utf8')); console.log('Total PRs:', d.total_count); console.log('3-4 days:', d.buckets.coming_up.length); console.log('5-6 days:', d.buckets.needs_attention.length); console.log('7-13 days:', d.buckets.stale.length); console.log('14+ days:', d.buckets.very_stale.length);" + env: + DIGEST_FILE: ${{ runner.temp }}/docs-review-digest.json + + - name: Upload digest artifact + uses: actions/upload-artifact@v5 + with: + name: docs-review-digest + path: ${{ runner.temp }}/docs-review-digest.json + retention-days: 7 + + - name: Render Slack digest message + run: node .github/scripts/render-docs-review-digest.js + env: + DIGEST_FILE: ${{ runner.temp }}/docs-review-digest.json + SLACK_MESSAGE_FILE: ${{ runner.temp }}/docs-review-digest-slack.txt + + - name: Upload Slack digest artifact + uses: actions/upload-artifact@v5 + with: + name: docs-review-digest-slack + path: ${{ runner.temp }}/docs-review-digest-slack.txt + retention-days: 7 + + - name: Post Slack digest + if: ${{ github.event_name == 'workflow_dispatch' && inputs.post_to_slack == 'true' }} + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_MESSAGE_FILE: ${{ runner.temp }}/docs-review-digest-slack.txt + run: | + if [ -z "$SLACK_WEBHOOK_URL" ]; then + echo "Missing SLACK_WEBHOOK_URL." + exit 1 + fi + + MESSAGE="$(cat "$SLACK_MESSAGE_FILE")" + + curl -X POST \ + -H "Content-type: application/json" \ + --data "$(jq -n --arg text "$MESSAGE" '{text: $text}')" \ + "$SLACK_WEBHOOK_URL" \ No newline at end of file