diff --git a/.github/workflows/pull-request-automation.yml b/.github/workflows/pull-request-automation.yml index 6691fec3341c9d..7a1fa210d091af 100644 --- a/.github/workflows/pull-request-automation.yml +++ b/.github/workflows/pull-request-automation.yml @@ -2,12 +2,15 @@ on: pull_request_target: types: [opened] push: + workflow_run: + workflows: [Build Gutenberg Plugin Zip] + types: [completed, in_progress] name: Pull request automation jobs: pull-request-automation: runs-on: ubuntu-latest - if: ${{ github.repository == 'WordPress/gutenberg' }} + if: ${{ true || github.repository == 'WordPress/gutenberg' }} strategy: matrix: node: ['16'] diff --git a/changelog.txt b/changelog.txt index 6d47c988544586..8844f19d0c62d5 100644 --- a/changelog.txt +++ b/changelog.txt @@ -224,6 +224,7 @@ ### Tools - Try: Change PR label enforcer automation not to work on draft PRs by default. ([53417](https://github.com/WordPress/gutenberg/pull/53417)) +- Insert build status comment on new PRs. ([54303](https://github.com/WordPress/gutenberg/pull/54303)) #### Testing - Attempt to fix intermittent end-to-end test failure. ([53905](https://github.com/WordPress/gutenberg/pull/53905)) diff --git a/packages/project-management-automation/README.md b/packages/project-management-automation/README.md index 3f2b4383c3a56e..171c9d28bf1b52 100644 --- a/packages/project-management-automation/README.md +++ b/packages/project-management-automation/README.md @@ -5,6 +5,7 @@ This is a [GitHub Action](https://help.github.com/en/categories/automating-your- - [First Time Contributor](https://github.com/WordPress/gutenberg/tree/HEAD/packages/project-management-automation/lib/tasks/first-time-contributor): Adds the "First Time Contributor" label to pull requests merged on behalf of contributors that have not previously made a contribution, and prompts the user to link their GitHub account to their WordPress.org profile if necessary for release notes credit. - [Add Milestone](https://github.com/WordPress/gutenberg/tree/HEAD/packages/project-management-automation/lib/tasks/add-milestone): Assigns the plugin release milestone to a pull request once it is merged. - [Assign Fixed Issues](https://github.com/WordPress/gutenberg/tree/HEAD/packages/project-management-automation/lib/tasks/assign-fixed-issues): Adds assignee for issues which are marked to be "Fixed" by a pull request, and adds the "In Progress" label. +- [PR Preview Link](https://github.com/WordPress/gutenberg/tree/HEAD/packages/project-management-automation/lib/tasks/pr-preview-link): Adds a comment to new PRs with a link to `http://gutenberg.run/{pr_number}` and Gutenberg plugin build file URL, making it easier for non-tech contributors to test. # Installation and usage @@ -37,4 +38,4 @@ This is an individual package that's part of the Gutenberg project. The project To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). -

Code is Poetry.

+

Code is Poetry.

\ No newline at end of file diff --git a/packages/project-management-automation/lib/index.js b/packages/project-management-automation/lib/index.js index b4f72a51efec6e..e9b8d13f7410cc 100644 --- a/packages/project-management-automation/lib/index.js +++ b/packages/project-management-automation/lib/index.js @@ -11,6 +11,7 @@ const assignFixedIssues = require( './tasks/assign-fixed-issues' ); const firstTimeContributorAccountLink = require( './tasks/first-time-contributor-account-link' ); const firstTimeContributorLabel = require( './tasks/first-time-contributor-label' ); const addMilestone = require( './tasks/add-milestone' ); +const prPreviewLink = require( './tasks/pr-preview-link' ); const debug = require( './debug' ); /** @@ -52,6 +53,10 @@ const automations = [ event: 'push', task: addMilestone, }, + { + event: 'workflow_run', + task: prPreviewLink, + }, ]; ( async function main() { diff --git a/packages/project-management-automation/lib/tasks/pr-preview-link/README.md b/packages/project-management-automation/lib/tasks/pr-preview-link/README.md new file mode 100644 index 00000000000000..6cce7ac128b633 --- /dev/null +++ b/packages/project-management-automation/lib/tasks/pr-preview-link/README.md @@ -0,0 +1,12 @@ +PR Preview Link +=== + +Adds a comment to new PRs with a comment about Gutenberg plugin build status: +- Latest commit +- Build status of [Build Gutenberg Plugin Zip](https://github.com/WordPress/gutenberg/blob/d19eb92d96886f4bb8e1028c5d54d365e37d71e4/.github/workflows/build-plugin-zip.yml#L1) +- Link to live preview `http://gutenberg.run/{pr_number}` +- Link to artifact download URL + +## Rationale + +Preview sites make PRs much easier to test, especially for folks who don't have a dev environment setup. The comment also saves contributors some extra clicks to download the latest build of the Gutenberg plugin. \ No newline at end of file diff --git a/packages/project-management-automation/lib/tasks/pr-preview-link/index.js b/packages/project-management-automation/lib/tasks/pr-preview-link/index.js new file mode 100644 index 00000000000000..8fab5ccb471377 --- /dev/null +++ b/packages/project-management-automation/lib/tasks/pr-preview-link/index.js @@ -0,0 +1,174 @@ +// @ts-nocheck + +/** + * Internal dependencies + */ +const debug = require( '../../debug' ); + +/** @typedef {ReturnType} GitHub */ +/** @typedef {import('@octokit/webhooks-types').WorkflowRunEvent} WorkflowRunEvent */ + +/** + * Identifier used to find existing comment. + * + * @type {string} + */ +const COMMENT_PLACEHOLDER = 'gutenberg-run-placeholder:cmt@v1'; + +const createBuildSummary = async ( + { buildStatus, latestCommit, pullRequestNumber, artifact }, + octokit +) => { + let status, previewMsg, artifactMsg; + status = previewMsg = artifactMsg = 'Building in progress...'; + if ( buildStatus === 'success' ) { + status = 'Build successful!'; + previewMsg = `[gutenberg.run/${ pullRequestNumber }](gutenberg.run/${ pullRequestNumber } )`; + artifactMsg = `[gutenberg-plugin](${ artifact.url }) - ${ artifact.size } MB`; + } else if ( buildStatus === 'failure' ) { + status = previewMsg = artifactMsg = 'Build failed!'; + } + + const response = await octokit.rest.markdown.render( { + mode: 'gfm', + text: ` +# Gutenberg Plugin build status + +| Name | Result | +| ----------------------- | - | +| **Last commit:** | ${ latestCommit } | +| **Status**: | ${ status } | +| **Preview URL**: | ${ previewMsg } | +| **Gutenberg plugin zip**: | ${ artifactMsg } | + `, + } ); + return `${ response.data }`; +}; + +const writeComment = async ( + { owner, repo, pullRequestNumber, commentBody }, + octokit +) => { + debug( 'pr-preview-link: Find and replace build status comment' ); + // First check if there is already a comment from this action + const comments = await octokit.rest.issues.listComments( { + issue_number: pullRequestNumber, + owner, + repo, + } ); + + const existingComment = comments.data.filter( ( comment ) => + comment.body.includes( COMMENT_PLACEHOLDER ) + ); + + if ( existingComment.length ) { + await octokit.rest.issues.updateComment( { + owner, + repo, + comment_id: existingComment[ 0 ].id, + body: commentBody, + } ); + } else { + await octokit.rest.issues.createComment( { + owner, + repo, + issue_number: pullRequestNumber, + body: commentBody, + } ); + } +}; + +/** + * Adds a comment to new PRs with a link to the corresponding gutenberg.run preview site. + * + * @param {WorkflowRunEvent} payload Workflow Run event payload. + * @param {GitHub} octokit Initialized Octokit REST client. + */ +async function prPreviewLink( payload, octokit ) { + const action = payload.action; + const repo = payload.repository.name; + const owner = payload.repository.owner.login; + const repoHtmlUrl = payload.repository.html_url; + const workflowRun = payload.workflow_run; + + if ( ! workflowRun || workflowRun?.event === 'workflow_dispatch' ) { + return; + } + + const workflowRunId = workflowRun.id; + const pullRequestNumber = workflowRun.pull_requests[ 0 ].number; + const checkSuiteId = workflowRun.check_suite_id; + const latestCommit = `${ repoHtmlUrl }/pull/${ pullRequestNumber }/commits/${ workflowRun.head_sha }`; + + if ( action === 'in_progress' ) { + const commentBody = await createBuildSummary( + { + buildStatus: action, + latestCommit, + pullRequestNumber, + artifact: null, + }, + octokit + ); + + await writeComment( + { owner, repo, pullRequestNumber, commentBody }, + octokit + ); + } + + if ( action === 'completed' ) { + debug( 'pr-preview-link: Build complete, request artifact from API' ); + const artifactsResponse = + await octokit.rest.actions.listWorkflowRunArtifacts( { + owner, + repo, + run_id: workflowRunId, + name: 'gutenberg-plugin', + per_page: 1, + } ); + const artifacts = artifactsResponse.data.artifacts; + + let commentBody; + if ( ! artifacts.length ) { + debug( 'pr-preview-link: No artifact found, mark as failure' ); + + commentBody = await createBuildSummary( + { + buildStatus: 'failure', + latestCommit, + pullRequestNumber, + artifactsUrl: null, + }, + octokit + ); + } else { + debug( 'pr-preview-link: Found artifact, mark as success' ); + + const artifact = artifacts[ 0 ]; + // The artifact URL on Checks screen + const artifactUrl = `${ repoHtmlUrl }/suites/${ checkSuiteId }/artifacts/${ artifact.id }`; + const sizeInMB = artifact.size_in_bytes / ( 1024 * 1024 ); + commentBody = await createBuildSummary( + { + buildStatus: 'success', + latestCommit, + pullRequestNumber, + artifact: { + url: artifactUrl, + size: sizeInMB.toFixed( 2 ), + }, + }, + octokit + ); + } + + await writeComment( + { owner, repo, pullRequestNumber, commentBody }, + octokit + ); + } +} + +module.exports = prPreviewLink; +module.exports.COMMENT_PLACEHOLDER = COMMENT_PLACEHOLDER; diff --git a/packages/project-management-automation/lib/tasks/pr-preview-link/test/index.js b/packages/project-management-automation/lib/tasks/pr-preview-link/test/index.js new file mode 100644 index 00000000000000..a695d83a5ff1fd --- /dev/null +++ b/packages/project-management-automation/lib/tasks/pr-preview-link/test/index.js @@ -0,0 +1,310 @@ +/** + * Internal dependencies + */ +import prPreviewLink, { COMMENT_PLACEHOLDER } from '../'; + +describe( 'prPreviewLink', () => { + it( 'does nothing if event is not workflow_run', async () => { + const payload = { + workflow_run: null, + repository: { + name: 'gutenberg', + owner: 'WordPress', + }, + }; + const octokit = { + rest: { + actions: { + listWorkflowRunArtifacts: jest.fn(), + }, + issues: { + listComments: jest.fn(), + updateComment: jest.fn(), + createComment: jest.fn(), + }, + markdown: { + render: jest.fn(), + }, + }, + }; + + await prPreviewLink( payload, octokit ); + + expect( octokit.rest.markdown.render ).not.toHaveBeenCalled(); + expect( octokit.rest.issues.listComments ).not.toHaveBeenCalled(); + expect( octokit.rest.issues.updateComment ).not.toHaveBeenCalled(); + expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( + octokit.rest.actions.listWorkflowRunArtifacts + ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if action is neither in_progress nor completed', async () => { + const payload = { + action: 'requested', + repository: { + name: 'gutenberg', + owner: { + login: 'WordPress', + }, + html_url: 'https://github.com/WordPress/gutenberg', + }, + workflow_run: { + event: 'workflow_run', + id: 123, + pull_requests: [ + { + number: 456, + }, + ], + head_sha: 'abcdef12345', + }, + }; + + // Mock Octokit methods + const octokit = { + rest: { + markdown: { + render: jest.fn(), + }, + }, + }; + + // Call prPreviewLink with the mock payload and octokit + await prPreviewLink( payload, octokit ); + + // Expect the markdown.render method not to be called + expect( octokit.rest.markdown.render ).not.toHaveBeenCalled(); + } ); + + it( 'renders the correct Markdown content for an in-progress build', async () => { + const payload = { + action: 'in_progress', + repository: { + name: 'gutenberg', + owner: { + login: 'WordPress', + }, + html_url: 'https://github.com/WordPress/gutenberg', + }, + workflow_run: { + event: 'workflow_run', + id: 123, + pull_requests: [ + { + number: 456, + }, + ], + head_sha: 'abcdef12345', + }, + }; + + // Mock Octokit methods + const octokit = { + rest: { + markdown: { + render: jest.fn( () => + Promise.resolve( { data: 'Mocked Markdown Output' } ) + ), + }, + issues: { + listComments: jest.fn( () => + Promise.resolve( { data: [] } ) + ), + updateComment: jest.fn(), + createComment: jest.fn(), + }, + }, + }; + + // Call prPreviewLink with the mock payload and octokit + await prPreviewLink( payload, octokit ); + + expect( octokit.rest.markdown.render ).toHaveBeenCalledWith( { + mode: 'gfm', + text: expect.stringContaining( 'Building in progress...' ), + } ); + expect( octokit.rest.issues.listComments ).toHaveBeenCalledWith( { + issue_number: 456, + owner: 'WordPress', + repo: 'gutenberg', + } ); + expect( octokit.rest.issues.createComment ).toHaveBeenCalledWith( { + issue_number: 456, + owner: 'WordPress', + repo: 'gutenberg', + body: `Mocked Markdown Output`, + } ); + expect( octokit.rest.issues.updateComment ).not.toHaveBeenCalled(); + } ); + + it( 'adds a comment on successful build', async () => { + const payload = { + action: 'completed', + repository: { + name: 'gutenberg', + owner: { + login: 'WordPress', + }, + html_url: 'https://github.com/WordPress/gutenberg', + }, + workflow_run: { + event: 'workflow_run', + id: 123, + pull_requests: [ + { + number: 456, + }, + ], + check_suite_id: 789, + head_sha: 'abcdef12345', + }, + }; + + // Mock Octokit methods + const octokit = { + rest: { + actions: { + listWorkflowRunArtifacts: jest.fn( () => { + return Promise.resolve( { + data: { + artifacts: [ + { + id: '987', + size_in_bytes: 1024 * 1024, // 1 MB + }, + ], + }, + } ); + } ), + }, + issues: { + listComments: jest.fn( () => { + // Mock an existing comment from the same action + return Promise.resolve( { + data: [ + { + id: 789, + body: `Mocked existing comment`, + }, + ], + } ); + } ), + updateComment: jest.fn(), + createComment: jest.fn(), + }, + markdown: { + render: jest.fn( () => { + return Promise.resolve( { + data: 'Mocked rendered comment', + } ); + } ), + }, + }, + }; + + // Call prPreviewLink with the mock payload and octokit + await prPreviewLink( payload, octokit ); + + expect( octokit.rest.markdown.render ).toHaveBeenCalledWith( { + mode: 'gfm', + text: expect.stringContaining( 'Build successful!' ), + } ); + // Expect markdown render to contain the artifact URL + expect( octokit.rest.markdown.render ).toHaveBeenCalledWith( { + mode: 'gfm', + text: expect.stringContaining( + '[gutenberg-plugin](https://github.com/WordPress/gutenberg/suites/789/artifacts/987) - 1.00 MB' + ), + } ); + // Expect the existing comment to be updated + expect( octokit.rest.issues.updateComment ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + comment_id: 789, // Existing comment ID + body: `Mocked rendered comment`, + } ); + expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( + octokit.rest.actions.listWorkflowRunArtifacts + ).toHaveBeenCalled(); + } ); + + it( 'adds a comment on failed build', async () => { + const payload = { + action: 'completed', + repository: { + name: 'gutenberg', + owner: { + login: 'WordPress', + }, + html_url: 'https://github.com/WordPress/gutenberg', + }, + workflow_run: { + event: 'workflow_run', + id: 123, + pull_requests: [ + { + number: 456, + }, + ], + head_sha: 'abcdef12345', + }, + }; + + // Mock Octokit methods + const octokit = { + rest: { + actions: { + listWorkflowRunArtifacts: jest.fn( () => { + return Promise.resolve( { + data: { + artifacts: [], // Simulate no artifacts found for a failed build + }, + } ); + } ), + }, + issues: { + listComments: jest.fn( () => { + // Mock an existing comment from the same action + return Promise.resolve( { + data: [ + { + id: 789, + body: `Mocked existing comment`, + }, + ], + } ); + } ), + updateComment: jest.fn(), + createComment: jest.fn(), + }, + markdown: { + render: jest.fn( () => { + return Promise.resolve( { + data: 'Mocked failed build comment', + } ); + } ), + }, + }, + }; + + // Call prPreviewLink with the mock payload and octokit + await prPreviewLink( payload, octokit ); + + expect( octokit.rest.markdown.render ).toHaveBeenCalledWith( { + mode: 'gfm', + text: expect.stringContaining( 'Build failed!' ), + } ); + expect( octokit.rest.issues.updateComment ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + comment_id: 789, // Existing comment ID + body: `Mocked failed build comment`, + } ); + expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( + octokit.rest.actions.listWorkflowRunArtifacts + ).toHaveBeenCalled(); + } ); +} );