diff --git a/.github/workflows/check-post-publish-date.yml b/.github/workflows/check-post-publish-date.yml new file mode 100644 index 0000000000000..0719fb4236234 --- /dev/null +++ b/.github/workflows/check-post-publish-date.yml @@ -0,0 +1,70 @@ +# Security Notes +# This workflow uses `pull_request`, so will run against PRs in their own context; be careful with allowing any user-provided code to be run here +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. +# MERGE QUEUE NOTE: This workflow runs on `pull_request` to ensure blog post dates are validated before merging + +name: Check Blog Post Publish Dates + +on: + pull_request: + paths: + - 'apps/site/pages/en/blog/**/*.md' + +defaults: + run: + working-directory: ./ + +permissions: + contents: read + actions: read + +jobs: + check-dates: + name: Check for future blog post dates + runs-on: ubuntu-latest + + permissions: + pull-requests: write + contents: read + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Git Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup Node.js + uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v5.0.0 + with: + node-version-file: '.nvmrc' + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check blog post publish dates + # Scan all blog posts for future publish dates + id: check-dates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + process.chdir('${{github.workspace}}/apps/site'); + const { checkAndFormatBlogDates } = await import('${{github.workspace}}/apps/site/scripts/check-blog-dates/index.mjs'); + await checkAndFormatBlogDates({core, github, context}); + + - name: Create PR review comments + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + FUTURE_POSTS_JSON: ${{ steps.check-dates.outputs.FUTURE_POSTS_JSON }} + with: + script: | + const { createReviewForFutureDates } = await import('${{github.workspace}}/apps/site/scripts/check-blog-dates/create-review.mjs') + await createReviewForFutureDates({github, context, core}) diff --git a/apps/site/scripts/check-blog-dates/__tests__/create-review.test.mjs b/apps/site/scripts/check-blog-dates/__tests__/create-review.test.mjs new file mode 100644 index 0000000000000..314c78ef79214 --- /dev/null +++ b/apps/site/scripts/check-blog-dates/__tests__/create-review.test.mjs @@ -0,0 +1,432 @@ +import assert from 'node:assert/strict'; +import { describe, it, beforeEach, afterEach } from 'node:test'; + +import { + createReviewForFutureDates, + buildCommentBody, + BOT_USER_LOGIN, + COMMENT_IDENTIFIER, +} from '../create-review.mjs'; + +const OLD_ENV = process.env; + +beforeEach(() => { + process.env = { ...OLD_ENV }; +}); + +afterEach(() => { + process.env = OLD_ENV; +}); + +describe('buildCommentBody', () => { + it('should return resolved message when no future posts', () => { + const result = buildCommentBody([]); + + assert.ok( + result.includes(COMMENT_IDENTIFIER), + 'The comment body should include the COMMENT_IDENTIFIER' + ); + assert.ok( + result.includes('Future Blog Posts Status'), + 'The comment body should include the status' + ); + assert.ok( + result.includes('have been resolved'), + 'The comment body should indicate that the posts have been resolved' + ); + }); + + it('should format table with single future post', () => { + const posts = [ + { + slug: '/blog/test-post', + title: 'Test Post', + date: '2099-12-31T00:00:00.000Z', + daysInFuture: 100, + }, + ]; + + const result = buildCommentBody(posts); + + assert.ok( + result.includes(COMMENT_IDENTIFIER), + 'The comment body should include the COMMENT_IDENTIFIER' + ); + assert.ok( + result.includes('Future Blog Posts Detected'), + 'The comment body should include the detection message' + ); + assert.ok( + result.includes('**1 post scheduled in the future**'), + 'The comment body should include the number of future posts' + ); + assert.ok( + result.includes('/blog/test-post'), + 'The comment body should include the post slug' + ); + assert.ok( + result.includes('100 days'), + 'The comment body should include the number of days in the future' + ); + assert.ok( + result.includes('Scheduled'), + 'The comment body should include the status' + ); + }); + + it('should format table with multiple future posts', () => { + const posts = [ + { + slug: '/blog/post-1', + title: 'Post 1', + date: '2099-01-01T00:00:00.000Z', + daysInFuture: 50, + }, + { + slug: '/blog/post-2', + title: 'Post 2', + date: '2099-02-01T00:00:00.000Z', + daysInFuture: 80, + }, + ]; + + const result = buildCommentBody(posts); + + assert.ok( + result.includes('**2 posts scheduled in the future**'), + 'The comment body should include the number of future posts' + ); + assert.ok( + result.includes('/blog/post-1'), + 'The comment body should include the first post slug' + ); + assert.ok( + result.includes('/blog/post-2'), + 'The comment body should include the second post slug' + ); + assert.ok( + result.includes('50 days'), + 'The comment body should include the number of days in the future for the first post' + ); + assert.ok( + result.includes('80 days'), + 'The comment body should include the number of days in the future for the second post' + ); + }); + + it('should handle singular day correctly', () => { + const posts = [ + { + slug: '/blog/test', + title: 'Test', + date: '2099-01-01T00:00:00.000Z', + daysInFuture: 1, + }, + ]; + + const result = buildCommentBody(posts); + + assert.ok( + result.includes('1 day'), + "The comment body should use 'day' for a single day" + ); + assert.ok( + !result.includes('1 days'), + "The comment body should not use 'days' for a single day" + ); + }); +}); + +describe('createReviewForFutureDates', () => { + let mockGithub, mockContext, mockCore; + + const setFuturePostsEnv = posts => { + process.env.FUTURE_POSTS_JSON = JSON.stringify(posts); + }; + + beforeEach(t => { + delete process.env.FUTURE_POSTS_JSON; + mockGithub = { + rest: { + issues: { + createComment: t.mock.fn(() => Promise.resolve({ data: {} })), + updateComment: t.mock.fn(() => Promise.resolve({ data: {} })), + listComments: t.mock.fn(() => Promise.resolve({ data: [] })), + }, + }, + }; + + mockContext = { + repo: { owner: 'nodejs', repo: 'nodejs.org' }, + issue: { number: 123 }, + payload: { + pull_request: { + head: { sha: 'abc123' }, + }, + }, + }; + + mockCore = { + info: t.mock.fn(), + warning: t.mock.fn(), + }; + }); + + afterEach(() => { + delete process.env.FUTURE_POSTS_JSON; + }); + + it('should skip when no future posts in env', async () => { + delete process.env.FUTURE_POSTS_JSON; + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + assert.equal(mockGithub.rest.issues.createComment.mock.calls.length, 0); + assert.equal(mockCore.info.mock.calls.length, 1); + assert.ok( + mockCore.info.mock.calls[0].arguments[0].includes( + 'No future posts found' + ), + 'should log that no future posts were found' + ); + }); + + describe('Comment Handling', () => { + const futurePost = { + slug: '/blog/test', + title: 'Test Post', + date: '2099-01-01T00:00:00.000Z', + daysInFuture: 100, + }; + + it('should create new comment when no existing comment', async () => { + setFuturePostsEnv([futurePost]); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + assert.equal(mockGithub.rest.issues.createComment.mock.calls.length, 1); + assert.equal(mockGithub.rest.issues.updateComment.mock.calls.length, 0); + + const createCall = + mockGithub.rest.issues.createComment.mock.calls[0].arguments[0]; + assert.equal(createCall.issue_number, 123); + assert.ok( + createCall.body.includes('/blog/test'), + 'The comment body should include the post slug' + ); + assert.ok( + createCall.body.includes(COMMENT_IDENTIFIER), + 'The comment body should include the COMMENT_IDENTIFIER' + ); + }); + + it('should update existing comment', async () => { + setFuturePostsEnv([futurePost]); + + mockGithub.rest.issues.listComments.mock.mockImplementation(() => + Promise.resolve({ + data: [ + { + id: 456, + user: { login: BOT_USER_LOGIN }, + body: `${COMMENT_IDENTIFIER}\nOld content`, + }, + ], + }) + ); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + assert.equal(mockGithub.rest.issues.createComment.mock.calls.length, 0); + assert.equal(mockGithub.rest.issues.updateComment.mock.calls.length, 1); + + const updateCall = + mockGithub.rest.issues.updateComment.mock.calls[0].arguments[0]; + assert.equal(updateCall.comment_id, 456); + assert.ok( + updateCall.body.includes('/blog/test'), + 'The comment body should include the post slug' + ); + }); + + it('should handle comment creation failure gracefully', async () => { + setFuturePostsEnv([futurePost]); + + mockGithub.rest.issues.createComment.mock.mockImplementation(() => + Promise.reject(new Error('API Error')) + ); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + assert.equal(mockCore.warning.mock.calls.length, 1); + assert.ok( + mockCore.warning.mock.calls[0].arguments[0].includes( + 'Failed to upsert comment' + ), + 'should warn about the failure' + ); + }); + + it('should handle comment update failure gracefully', async () => { + setFuturePostsEnv([futurePost]); + + mockGithub.rest.issues.listComments.mock.mockImplementation(() => + Promise.resolve({ + data: [ + { + id: 456, + user: { login: BOT_USER_LOGIN }, + body: `${COMMENT_IDENTIFIER}\nOld`, + }, + ], + }) + ); + + mockGithub.rest.issues.updateComment.mock.mockImplementation(() => + Promise.reject(new Error('API Error')) + ); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + assert.equal(mockCore.warning.mock.calls.length, 1); + assert.ok( + mockCore.warning.mock.calls[0].arguments[0].includes( + 'Failed to upsert comment' + ), + 'should warn about the failure' + ); + }); + + it('should skip non-bot comments when finding existing comment', async () => { + setFuturePostsEnv([futurePost]); + + mockGithub.rest.issues.listComments.mock.mockImplementation(() => + Promise.resolve({ + data: [ + { + id: 456, + user: { login: 'human-user' }, + body: `${COMMENT_IDENTIFIER}\nHuman comment`, + }, + ], + }) + ); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + // Should create new comment, not update human's comment + assert.equal(mockGithub.rest.issues.createComment.mock.calls.length, 1); + assert.equal(mockGithub.rest.issues.updateComment.mock.calls.length, 0); + }); + }); + + describe('Post Filtering', () => { + it('should filter out resolved posts based on current date', async () => { + setFuturePostsEnv([ + { + slug: '/blog/past-post', + title: 'Past Post', + date: '2020-01-01T00:00:00.000Z', // In the past + daysInFuture: 1, + }, + { + slug: '/blog/future-post', + title: 'Future Post', + date: '2099-01-01T00:00:00.000Z', // In the future + daysInFuture: 100, + }, + ]); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + const createCall = + mockGithub.rest.issues.createComment.mock.calls[0].arguments[0]; + // Should only show Future Post (Past Post is resolved by current date) + assert.ok( + createCall.body.includes('/blog/future-post'), + 'The comment body should include the future post' + ); + assert.ok( + !createCall.body.includes('/blog/past-post'), + 'The comment body should not include the past post' + ); + assert.ok( + createCall.body.includes('1 post scheduled'), + 'The comment body should include the number of future posts' + ); + }); + + it('should show resolved message when all posts are resolved by current date', async () => { + setFuturePostsEnv([ + { + slug: '/blog/past-post', + title: 'Past Post', + date: '2020-01-01T00:00:00.000Z', + daysInFuture: 1, + }, + ]); + + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + + const createCall = + mockGithub.rest.issues.createComment.mock.calls[0].arguments[0]; + assert.ok( + createCall.body.includes('Future Blog Posts Status'), + 'The comment body should include the status' + ); + assert.ok( + !createCall.body.includes('/blog/past-post'), + 'The comment body should not include the past post' + ); + }); + }); + + it('should handle malformed FUTURE_POSTS_JSON', async () => { + process.env.FUTURE_POSTS_JSON = 'invalid json {'; + + await assert.rejects( + async () => { + await createReviewForFutureDates({ + github: mockGithub, + context: mockContext, + core: mockCore, + }); + }, + { + name: 'SyntaxError', + } + ); + }); +}); diff --git a/apps/site/scripts/check-blog-dates/__tests__/index.test.mjs b/apps/site/scripts/check-blog-dates/__tests__/index.test.mjs new file mode 100644 index 0000000000000..59f565a4bd642 --- /dev/null +++ b/apps/site/scripts/check-blog-dates/__tests__/index.test.mjs @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import { describe, it, beforeEach, afterEach } from 'node:test'; + +import { checkBlogDates, checkAndFormatBlogDates } from '../index.mjs'; + +const OLD_ENV = process.env; + +beforeEach(() => { + process.env = { ...OLD_ENV }; +}); + +afterEach(() => { + process.env = OLD_ENV; +}); + +describe('checkBlogDates', () => { + const mockBlogData = { + posts: [ + { slug: '/blog/past-post', title: 'Past Post', date: '2024-01-01' }, + { slug: '/blog/future-post', title: 'Future Post', date: '2025-12-31' }, + { + slug: '/blog/another-future', + title: 'Another Future', + date: '2025-11-15', + }, + ], + }; + + it('should return no future posts when all posts are in the past', async () => { + const mockGenerator = async () => mockBlogData; + const currentDate = new Date('2026-01-01'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + assert.equal(result.hasFuturePosts, false); + assert.equal(result.futurePosts.length, 0); + }); + + it('should detect future posts correctly', async () => { + const mockGenerator = async () => mockBlogData; + const currentDate = new Date('2025-01-01'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + assert.equal(result.hasFuturePosts, true); + assert.equal(result.futurePosts.length, 2); + assert.equal(result.futurePosts[0].slug, '/blog/future-post'); + assert.equal(result.futurePosts[1].slug, '/blog/another-future'); + }); + + it('should calculate days in future correctly', async () => { + const mockGenerator = async () => mockBlogData; + const currentDate = new Date('2025-12-30'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + assert.equal(result.hasFuturePosts, true); + assert.equal(result.futurePosts.length, 1); + assert.equal(result.futurePosts[0].daysInFuture, 1); + }); + + it('should handle empty blog data', async () => { + const mockGenerator = async () => ({ posts: [] }); + const currentDate = new Date('2025-01-01'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + assert.equal(result.hasFuturePosts, false); + assert.equal(result.futurePosts.length, 0); + }); + + it('should include all required fields in future posts', async () => { + const mockGenerator = async () => mockBlogData; + const currentDate = new Date('2025-01-01'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + result.futurePosts.forEach(post => { + assert.ok(post.slug, 'post should have a slug'); + assert.ok(post.title, 'post should have a title'); + assert.ok(post.date, 'post should have a date'); + assert.ok( + typeof post.daysInFuture === 'number', + 'daysInFuture should be a number' + ); + assert.ok(post.daysInFuture > 0, 'daysInFuture should be greater than 0'); + }); + }); + + it('should handle post date exactly equal to current date', async () => { + const mockData = { + posts: [ + { + slug: '/blog/exact', + title: 'Exact', + date: '2025-01-01T00:00:00.000Z', + }, + ], + }; + const mockGenerator = async () => mockData; + const currentDate = new Date('2025-01-01T00:00:00.000Z'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + assert.equal(result.hasFuturePosts, false); + assert.equal(result.futurePosts.length, 0); + }); + + it('should handle timezone boundaries correctly', async () => { + const mockData = { + posts: [ + { + slug: '/blog/tz-test', + title: 'TZ Test', + date: '2025-01-01T23:59:59.999Z', + }, + ], + }; + const mockGenerator = async () => mockData; + const currentDate = new Date('2025-01-01T23:59:59.998Z'); + + const result = await checkBlogDates(currentDate, mockGenerator); + + assert.equal(result.hasFuturePosts, true); + assert.equal(result.futurePosts.length, 1); + }); + + it('should handle blogDataGenerator failure in checkBlogDates', async () => { + const mockGenerator = async () => { + throw new Error('Generator Error'); + }; + const currentDate = new Date('2025-01-01'); + + await assert.rejects( + async () => { + await checkBlogDates(currentDate, mockGenerator); + }, + { + name: 'Error', + message: 'Generator Error', + } + ); + }); +}); + +describe('checkAndFormatBlogDates', () => { + let mockCore, mockGithub, mockContext; + + beforeEach(t => { + mockCore = { + info: t.mock.fn(), + setOutput: t.mock.fn(), + warning: t.mock.fn(), + }; + + mockGithub = {}; + + mockContext = { + repo: { owner: 'nodejs', repo: 'nodejs.org' }, + payload: { + pull_request: { + number: 123, + head: { sha: 'abc123' }, + }, + }, + }; + }); + + it('should skip when not in PR context (no github)', async () => { + await checkAndFormatBlogDates({ + core: mockCore, + github: null, + context: mockContext, + }); + + assert.equal(mockCore.info.mock.calls.length, 1); + assert.ok( + mockCore.info.mock.calls[0].arguments[0].includes( + 'Not running in a PR context' + ), + 'should log that it is not running in a PR context' + ); + assert.equal(mockCore.setOutput.mock.calls.length, 1); + assert.equal( + mockCore.setOutput.mock.calls[0].arguments[0], + 'FUTURE_POSTS_JSON' + ); + assert.equal(mockCore.setOutput.mock.calls[0].arguments[1], '[]'); + }); + + it('should skip when not in PR context (no context)', async () => { + await checkAndFormatBlogDates({ + core: mockCore, + github: mockGithub, + context: null, + }); + + assert.equal(mockCore.info.mock.calls.length, 1); + assert.ok( + mockCore.info.mock.calls[0].arguments[0].includes( + 'Not running in a PR context' + ), + 'should log that it is not running in a PR context' + ); + assert.equal(mockCore.setOutput.mock.calls.length, 1); + assert.equal( + mockCore.setOutput.mock.calls[0].arguments[0], + 'FUTURE_POSTS_JSON' + ); + assert.equal(mockCore.setOutput.mock.calls[0].arguments[1], '[]'); + }); + + it('should skip when not in PR context (no pull_request)', async () => { + await checkAndFormatBlogDates({ + core: mockCore, + github: mockGithub, + context: { repo: { owner: 'nodejs', repo: 'nodejs.org' } }, + }); + + assert.equal(mockCore.info.mock.calls.length, 1); + assert.ok( + mockCore.info.mock.calls[0].arguments[0].includes( + 'Not running in a PR context' + ), + 'should log that it is not running in a PR context' + ); + assert.equal(mockCore.setOutput.mock.calls.length, 1); + assert.equal( + mockCore.setOutput.mock.calls[0].arguments[0], + 'FUTURE_POSTS_JSON' + ); + assert.equal(mockCore.setOutput.mock.calls[0].arguments[1], '[]'); + }); + + it('should set FUTURE_POSTS_JSON to empty array when no future posts exist', async () => { + const mockBlogDataGenerator = async () => ({ + futurePosts: [], + hasFuturePosts: false, + }); + + await checkAndFormatBlogDates({ + core: mockCore, + github: mockGithub, + context: mockContext, + blogDataGenerator: mockBlogDataGenerator, + }); + + const jsonCall = mockCore.setOutput.mock.calls.find( + call => call.arguments[0] === 'FUTURE_POSTS_JSON' + ); + assert.ok(jsonCall, 'FUTURE_POSTS_JSON should be set'); + assert.equal(jsonCall.arguments[1], '[]'); + }); + + it('should set FUTURE_POSTS_JSON when future posts exist', async () => { + const mockBlogDataGenerator = async () => ({ + futurePosts: [ + { + slug: '/blog/future-post', + title: 'Future Post', + date: '2099-12-31T00:00:00.000Z', + daysInFuture: 100, + }, + ], + hasFuturePosts: true, + }); + + await checkAndFormatBlogDates({ + core: mockCore, + github: mockGithub, + context: mockContext, + blogDataGenerator: mockBlogDataGenerator, + }); + + const jsonCall = mockCore.setOutput.mock.calls.find( + call => call.arguments[0] === 'FUTURE_POSTS_JSON' + ); + assert.ok(jsonCall, 'FUTURE_POSTS_JSON should be set'); + assert.ok( + jsonCall.arguments[1].includes('/blog/future-post'), + 'The JSON should include the future post' + ); + }); + + it('should handle blogDataGenerator failure in checkAndFormatBlogDates', async () => { + const mockBlogDataGenerator = async () => { + throw new Error('Generator Error'); + }; + + await assert.rejects( + async () => { + await checkAndFormatBlogDates({ + core: mockCore, + github: mockGithub, + context: mockContext, + blogDataGenerator: mockBlogDataGenerator, + }); + }, + { + name: 'Error', + message: 'Generator Error', + } + ); + }); +}); diff --git a/apps/site/scripts/check-blog-dates/create-review.mjs b/apps/site/scripts/check-blog-dates/create-review.mjs new file mode 100644 index 0000000000000..2cba7db2f6879 --- /dev/null +++ b/apps/site/scripts/check-blog-dates/create-review.mjs @@ -0,0 +1,164 @@ +// Constants +export const BOT_USER_LOGIN = 'github-actions[bot]'; +export const COMMENT_IDENTIFIER = ''; + +// ============================================================================ +// TABLE FORMATTING +// ============================================================================ + +/** + * Formats a table row for a future post + */ +function formatPostRow(post) { + const date = new Date(post.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + const dayText = post.daysInFuture === 1 ? 'day' : 'days'; + const daysUntil = `${post.daysInFuture} ${dayText}`; + const status = 'Scheduled'; + + return `${post.slug} | ${date} | ${daysUntil} | ${status}`; +} + +/** + * Builds the comment body with a table of future posts + */ +export function buildCommentBody(futurePosts) { + if (futurePosts.length === 0) { + return `${COMMENT_IDENTIFIER} +## Future Blog Posts Status + +All blog posts with future publish dates have been resolved.`; + } + + const header = [ + `${COMMENT_IDENTIFIER}`, + '## Future Blog Posts Detected', + '', + `**${futurePosts.length} post${futurePosts.length === 1 ? '' : 's'} scheduled in the future**, please make sure this date is correct.`, + '', + 'Post | Scheduled Date | Time Until | Status', + '| - | - | - | - |', + ]; + + const rows = futurePosts.map(formatPostRow); + + return [...header, ...rows].join('\n'); +} + +// ============================================================================ +// COMMENT MANAGEMENT +// ============================================================================ + +/** + * Finds existing bot comment on the PR + */ +async function findExistingComment({ github, requestContext }) { + const { data: comments } = await github.rest.issues.listComments({ + ...requestContext, + }); + + return comments.find( + comment => + comment.user.login === BOT_USER_LOGIN && + comment.body.includes(COMMENT_IDENTIFIER) + ); +} + +/** + * Creates or updates the PR comment with future posts status + */ +async function upsertComment({ + github, + requestContext, + commentBody, + existingComment, + core, +}) { + try { + if (existingComment) { + await github.rest.issues.updateComment({ + owner: requestContext.owner, + repo: requestContext.repo, + comment_id: existingComment.id, + body: commentBody, + }); + core.info('Updated existing future posts comment'); + return; + } + + await github.rest.issues.createComment({ + ...requestContext, + body: commentBody, + }); + core.info('Created new future posts comment'); + return; + } catch (error) { + core.warning(`Failed to upsert comment: ${error.message}`); + } +} + +// ============================================================================ +// MAIN ENTRY POINT +// ============================================================================ + +/** + * Creates or updates a PR comment for blog posts with future publish dates. + * + * Process: + * 1. Parse future posts from environment + * 2. Get current date to check for resolved posts + * 3. Filter out posts that are resolved (current date >= post date) + * 4. Build comment body with table format + * 5. Create or update PR comment + */ +export async function createReviewForFutureDates({ github, context, core }) { + // Parse environment - JSON string set by previous step in workflow + const futurePostsJson = process.env.FUTURE_POSTS_JSON; + if (!futurePostsJson) { + core.info('No future posts found, skipping comment creation'); + return; + } + + const allFuturePosts = JSON.parse(futurePostsJson); + + // Build request context + const requestContext = { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }; + + // Get current date/time + const currentDate = new Date(); + + core.info( + `Checking future posts against current date: ${currentDate.toISOString()}` + ); + + // Filter out posts that are resolved (post date <= current date) + const unresolvedPosts = allFuturePosts.filter( + post => new Date(post.date) > currentDate + ); + + core.info( + `Found ${unresolvedPosts.length} unresolved future post(s) (${allFuturePosts.length - unresolvedPosts.length} resolved by date)` + ); + + // Find existing comment + const existingComment = await findExistingComment({ github, requestContext }); + + // Build comment body + const commentBody = buildCommentBody(unresolvedPosts); + + // Create or update comment + await upsertComment({ + github, + requestContext, + commentBody, + existingComment, + core, + }); +} diff --git a/apps/site/scripts/check-blog-dates/index.mjs b/apps/site/scripts/check-blog-dates/index.mjs new file mode 100644 index 0000000000000..f51d458830771 --- /dev/null +++ b/apps/site/scripts/check-blog-dates/index.mjs @@ -0,0 +1,70 @@ +import generateBlogData from '../../next-data/generators/blogData.mjs'; + +const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24; + +/** + * Checks all blog posts for future publish dates. + * + * This is a pure function that analyzes blog post dates and returns + * information about posts scheduled in the future. + * + * @param {Date} currentDate - The date to compare against (defaults to now) + * @param {Function} blogDataGenerator - Function to generate blog data (for testing) + * @returns {Promise} Object containing futurePosts array and hasFuturePosts boolean + */ +export async function checkBlogDates( + currentDate = new Date(), + blogDataGenerator = generateBlogData +) { + const blogData = await blogDataGenerator(); + + const futurePosts = blogData.posts.reduce((acc, post) => { + const postDate = new Date(post.date); + + if (postDate > currentDate) { + acc.push({ + slug: post.slug, + title: post.title, + date: postDate.toISOString(), + daysInFuture: Math.ceil( + (postDate - currentDate) / MILLISECONDS_PER_DAY + ), + }); + } + + return acc; + }, []); + + return { futurePosts, hasFuturePosts: futurePosts.length > 0 }; +} + +/** + * Checks blog dates and formats the results for GitHub Actions output. + * + * This function filters results to only posts that are in the current PR. + * If not running in a PR context, no results will be returned. + * + * Note: This function must be run from the apps/site directory. + * + * @param {Object} params - GitHub Actions utilities + * @param {Object} params.core - GitHub Actions core utilities for setting outputs + * @param {Object} params.github - GitHub API client (required for PR filtering) + * @param {Object} params.context - GitHub Actions context (required for PR filtering) + * @param {Function} params.blogDataGenerator - Blog data generator function (for testing) + */ +export async function checkAndFormatBlogDates({ + core, + github, + context, + blogDataGenerator = checkBlogDates, +}) { + if (!github || !context?.payload?.pull_request) { + core.info('Not running in a PR context, skipping future date checks'); + core.setOutput('FUTURE_POSTS_JSON', '[]'); + return; + } + + const { futurePosts } = await blogDataGenerator(); + + core.setOutput('FUTURE_POSTS_JSON', JSON.stringify(futurePosts)); +}