diff --git a/.github/workflows/action-test.yml b/.github/workflows/action-test.yml index a779e74..c313546 100644 --- a/.github/workflows/action-test.yml +++ b/.github/workflows/action-test.yml @@ -22,6 +22,17 @@ jobs: test-data/tf_test2.json test-data/tf_test3.json + - name: Test PR comment hiding + uses: ./ + with: + json-file: | + test-data/tf_test.json + test-data/tf_test2.json + test-data/tf_test3.json + hide-previous-comments: true + comment-header: "Same as previous but previous comment should be hidden." + + - name: Test PR Comment Expand feature uses: ./ with: diff --git a/README.md b/README.md index d047c45..52878cc 100644 --- a/README.md +++ b/README.md @@ -12,25 +12,73 @@ Implementing this Action is _super_ simple and the comments are consise and easy - Display changes in a Terraform plan without posting larger sections of the plan change log. This approach will, in most cases, avoid the situation where plan contents are too large for a single PR comment. - Collapsed as a summary by default, when expanded, the comment is broken up into sections for deletion, creation, and resource changes. The changes are also color-coded to help draw attention to each proposed modification. - This JavaScript GitHub Action runs directly on a host runner and executes faster than a Docker container Action. +- Possibility to add the output to your workflow summary. +- Possibility to hide previous comments generated by this action. +- Possibility to not create any comments in case there are no infrastructure changes. +- Customize the header and the footer of the generated output. ### Example Comment ![terraform-changes](./assets/terraform-changes.png) ## Inputs -## `expand-comment` +### `json-file` + +**Optional** Defaults to `tfplan.json` + +- The location of the JSON file created by running `terraform show -no-color -json tfplan.plan > tfplan.json` (Or whatever you choose to name your plan or json outputs) + +- Multiple files can be provided using a text block. + +### `github-token` + +**Optional** Boolean defaults to `${{github.token}}` + +- Used to authenticate with the GitHub API. + +### `expand-comment` **Optional** Boolean defaults to `false` - Will expand the changes in comments by default rather than having them collapsed beneath the summary -### `json-file` +### `include-plan-job-summary` -**Optional** Defaults to `tfplan.json` +**Optional** Defaults to `false` -- The location of the JSON file created by running `terraform show -no-color -json tfplan.plan > tfplan.json` (Or whatever you choose to name your plan or json outputs) +- Will write the plan output to the workflow summary. -- Multiple files can be provided using a text block. +- The workflow summary will still be set when running this action outside of a PR context. + +### `comment-header` + +**Optional** Defaults to `Terraform Plan Changes` + +- Will set the header of the PR comment and/or workflow summary. + +### `comment-footer` + +**Optional** Defaults to `""` + +- Will set a footer of the PR comment and/or workflow summary. + +### `include-workflow-link` + +**Optional** Defaults to `false` + +- Will include a link back to the workflow in the PR comment and/or workflow summary. + +### `quiet` + +**Optional** Defaults to `false` + +- Will not create a PR comment when there are no infrastructure changes. + +### `hide-previous-comments` + +**Optional** Defaults to `false` + +- Will hide/minimize all previous comments generated by this action. ## Example usage Single plan file: @@ -61,6 +109,29 @@ with: #### Example Job Summary Output ![Plan output job summary](assets/plan-output-job-summary.png) +## Example usage with OpenTofu + +To use this action with OpenTofu you need to initialize OpenTofu without the wrapper, like discussed in the `known issues` below. + +**You also need to convert the planfile to a JSON planfile using the `tofu show -json` command.** + +```yaml + - uses: opentofu/setup-opentofu@v1 + with: + tofu_wrapper: false + + - name: Create planfile + run: tofu plan -no-color -out=./.planfile + + - name: Convert planfile to JSON planfile + run: tofu show -json ./.planfile >> ./my-planfile.json + + - name: Create PR comment + uses: liatrio/terraform-change-pr-commenter@v1.4.0 + with: + json-file: my-planfile.json +``` + ## Terraform Configuration / Known Issues #### Known issue when including the [Terraform Wrapper script](https://github.com/hashicorp/setup-terraform#inputs) - Execution may error with `Error: Unexpected token c in JSON at position 1` @@ -78,7 +149,7 @@ with: ### Contributions are welcome! If you'd like to suggest changes, feel free to submit a Pull Request or [open an issue](https://github.com/liatrio/terraform-change-pr-commenter/issues/new). -Otherwise if things aren't working as expected, please [open a new issue](https://github.com/liatrio/terraform-change-pr-commenter/issues/new). Pleae include code references, a description of the issue, and expected behavior. +Otherwise if things aren't working as expected, please [open a new issue](https://github.com/liatrio/terraform-change-pr-commenter/issues/new). Please include code references, a description of the issue, and expected behavior. --- ![CodeQL Security Scan](https://github.com/liatrio/terraform-change-pr-commenter/actions/workflows/codeql-analysis.yml/badge.svg?branch=main) diff --git a/action.yml b/action.yml index 0487a99..906809b 100644 --- a/action.yml +++ b/action.yml @@ -15,11 +15,11 @@ inputs: expand-comment: description: If true, expand the details comment by default required: false - default: "false" + default: false include-plan-job-summary: description: If true, add the results of the plan to the workflow job summary required: false - default: "false" + default: false comment-header: description: Header to use for the comment required: false @@ -36,6 +36,10 @@ inputs: description: Skips the comment if there are no changes required: false default: false + hide-previous-comments: + description: Hides privious comments on the PR + required: false + default: false runs: using: node20 main: dist/index.js diff --git a/dist/index.js b/dist/index.js index 9feddd4..220032b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -12828,62 +12828,87 @@ const commentHeader = core.getMultilineInput('comment-header'); const commentFooter = core.getMultilineInput('comment-footer'); const quietMode = core.getBooleanInput('quiet'); const includeLinkToWorkflow = core.getBooleanInput('include-workflow-link'); - +const hidePreviousComments = core.getBooleanInput('hide-previous-comments'); const workflowLink = includeLinkToWorkflow ? ` -[Workflow: ${context.workflow}](${ context.serverUrl }/${ context.repo.owner }/${ context.repo.repo }/actions/runs/${ context.runId }) +[Workflow: ${context.workflow}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId }) ` : ""; var hasNoChanges = false; +// GraphQL queries and mutations used for hiding previous comments +const minimizeCommentQuery = /* GraphQL */ ` + mutation minimizeComment($id: ID!) { + minimizeComment(input: { classifier: OUTDATED, subjectId: $id }) { + clientMutationId + } + } +`; + +const commentsQuery = /* GraphQL */ ` + query comments($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + comments(last: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { + nodes { + id + body + isMinimized + } + } + } + } + } +`; + const output = () => { - let body = ''; - // for each file - for (const file of inputFilenames) { - const resource_changes = JSON.parse(fs.readFileSync(file)).resource_changes; - try { - let changed_resources = resource_changes.filter((resource) => { - return resource.change.actions != ["no-op"]; - }) + let body = ''; + // for each file + for (const file of inputFilenames) { + const resource_changes = JSON.parse(fs.readFileSync(file)).resource_changes; + try { + let changed_resources = resource_changes.filter((resource) => { + return resource.change.actions != ["no-op"]; + }) - console.log("changed_resources", changed_resources) - if (Array.isArray(resource_changes) && resource_changes.length > 0) { - const resources_to_create = [], - resources_to_update = [], - resources_to_delete = [], - resources_to_replace = [], - resources_unchanged = []; - - // for each resource changes - for (const resource of resource_changes) { - const change = resource.change; - const address = resource.address; - - switch (change.actions[0]) { - default: - break; - case "no-op": - resources_unchanged.push(address); - break; - case "create": - resources_to_create.push(address); - break; - case "delete": - if (change.actions.length > 1) { - resources_to_replace.push(address); - } else { - resources_to_delete.push(address); - } - break; - case "update": - resources_to_update.push(address); - break; - } - } - // the body must be indented at the start otherwise - // there will be formatting error when comment is - // showed on GitHub - body += ` + console.log("changed_resources", changed_resources) + if (Array.isArray(resource_changes) && resource_changes.length > 0) { + const resources_to_create = [], + resources_to_update = [], + resources_to_delete = [], + resources_to_replace = [], + resources_unchanged = []; + + // for each resource changes + for (const resource of resource_changes) { + const change = resource.change; + const address = resource.address; + + switch (change.actions[0]) { + default: + break; + case "no-op": + resources_unchanged.push(address); + break; + case "create": + resources_to_create.push(address); + break; + case "delete": + if (change.actions.length > 1) { + resources_to_replace.push(address); + } else { + resources_to_delete.push(address); + } + break; + case "update": + resources_to_update.push(address); + break; + } + } + // the body must be indented at the start otherwise + // there will be formatting error when comment is + // showed on GitHub + body += ` ${commentHeader}
@@ -12897,83 +12922,138 @@ ${details("replace", resources_to_replace, "+")} ${commentFooter.map(a => a == '' ? '\n' : a).join('\n')} ${workflowLink} ` - if (resources_to_create + resources_to_delete + resources_to_update + resources_to_replace == []) { - hasNoChanges = true; - } - } else { - hasNoChanges = true; - console.log("No changes found in the plan. setting hasNoChanges to true.") - body += ` + if (resources_to_create + resources_to_delete + resources_to_update + resources_to_replace == []) { + hasNoChanges = true; + } + } else { + hasNoChanges = true; + console.log("No changes found in the plan. setting hasNoChanges to true.") + body += `

There were no changes done to the infrastructure.

` - core.info(`"The content of ${file} did not result in a valid array or the array is empty... Skipping."`) - } - } catch (error) { - core.error(`${file} is not a valid JSON file. error: ${error}`); - } + core.info(`"The content of ${file} did not result in a valid array or the array is empty... Skipping."`) + } + } catch (error) { + core.error(`${file} is not a valid JSON file. error: ${error}`); } - return body; + } + return body; } const details = (action, resources, operator) => { - let str = ""; + let str = ""; - if (resources.length !== 0) { - str = ` + if (resources.length !== 0) { + str = ` #### Resources to ${action}\n \`\`\`diff\n `; - for (const el of resources) { - // In the replace block, we show delete (-) and then create (+) - if (action === "replace") { - str += `- ${el}\n` - } - str += `${operator} ${el}\n` - } - - str += "```\n" + for (const el of resources) { + // In the replace block, we show delete (-) and then create (+) + if (action === "replace") { + str += `- ${el}\n` + } + str += `${operator} ${el}\n` } - return str; + str += "```\n" + } + + return str; } +const queryComments = (variables) => { + return octokit.graphql(commentsQuery, variables); +}; + +const minimizeComment = (variables) => { + return octokit.graphql(minimizeCommentQuery, variables); +}; + +const hideComments = () => { + core.info(`Hiding previous comments.`); + + queryComments({ + owner: context.repo.owner, + name: context.repo.repo, + number: context.issue.number + }) + .then(response => { + core.info(`Successfully retrieved comments for PR #${context.issue.number}.`); + const comments = response.repository.pullRequest.comments.nodes; + + core.info(`Found ${comments.length} comments in the PR.`); + + const filteredComments = comments.filter(comment => + comment.body.includes('Terraform Plan:') || + comment.body.includes('There were no changes done to the infrastructure.') + ); + + core.info(`Filtered down to ${filteredComments.length} comments that need to be minimized.`); + + const minimizePromises = filteredComments + .filter(comment => !comment.isMinimized) + .map(comment => { + return minimizeComment({ id: comment.id }) + .catch(error => core.error(`Failed to minimize comment ${comment.id}: ${error.message}`)); + }); + + return Promise.all(minimizePromises) + .then(() => core.info('All minimize operations completed.')) + .catch(error => core.error(`Error during minimize operations: ${error.message}`)); + }) + .catch(error => core.error(`Failed to retrieve comments: ${error.message}`)); +}; + + try { let rawOutput = output(); - if (includePlanSummary) { - core.info("Adding plan output to job summary") - core.summary.addHeading('Terraform Plan Results').addRaw(rawOutput).write() + let createComment = true; + + console.log("hidePreviousComments", hidePreviousComments) + console.log("hidePreviousComments && context.eventName === pull_request", hidePreviousComments && context.eventName === 'pull_request') + if (hidePreviousComments && context.eventName === 'pull_request') { + hideComments(); } - if (context.eventName === 'pull_request') { - core.info(`Found PR # ${context.issue.number} from workflow context - proceeding to comment.`) - } else { - core.warning("Action doesn't seem to be running in a PR workflow context.") - core.warning("Skipping comment creation.") - process.exit(0); + console.log("includePlanSummary", includePlanSummary) + if (includePlanSummary) { + core.info("Adding plan output to job summary") + core.summary.addHeading('Terraform Plan Results').addRaw(rawOutput).write() } console.log("quietMode", quietMode) console.log("hasNoChanges", hasNoChanges) console.log("quietMode && hasNoChanges", quietMode && hasNoChanges) if (quietMode && hasNoChanges) { - core.info("quiet mode is enabled and there are no changes to the infrastructure.") - core.info("Skipping comment creation.") - process.exit(0); + core.info("quiet mode is enabled and there are no changes to the infrastructure.") + core.info("Skipping comment creation.") + createComment = false } - core.info("Adding comment to PR"); - core.info(`Comment: ${rawOutput}`); + if (context.eventName === 'pull_request') { + core.info(`Found PR # ${context.issue.number} from workflow context - proceeding to comment.`) + } else { + core.info("Action doesn't seem to be running in a PR workflow context.") + core.info("Skipping comment creation.") + createComment = false + } - octokit.rest.issues.createComment({ + if (createComment) { + core.info("Adding comment to PR"); + core.info(`Comment: ${rawOutput}`); + octokit.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: rawOutput - }); + }); + core.info("Comment added successfully."); + } + } catch (error) { core.setFailed(error.message); } - })(); module.exports = __webpack_exports__; diff --git a/index.js b/index.js index fbf3718..d6a4015 100644 --- a/index.js +++ b/index.js @@ -12,62 +12,87 @@ const commentHeader = core.getMultilineInput('comment-header'); const commentFooter = core.getMultilineInput('comment-footer'); const quietMode = core.getBooleanInput('quiet'); const includeLinkToWorkflow = core.getBooleanInput('include-workflow-link'); - +const hidePreviousComments = core.getBooleanInput('hide-previous-comments'); const workflowLink = includeLinkToWorkflow ? ` -[Workflow: ${context.workflow}](${ context.serverUrl }/${ context.repo.owner }/${ context.repo.repo }/actions/runs/${ context.runId }) +[Workflow: ${context.workflow}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId }) ` : ""; var hasNoChanges = false; +// GraphQL queries and mutations used for hiding previous comments +const minimizeCommentQuery = /* GraphQL */ ` + mutation minimizeComment($id: ID!) { + minimizeComment(input: { classifier: OUTDATED, subjectId: $id }) { + clientMutationId + } + } +`; + +const commentsQuery = /* GraphQL */ ` + query comments($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + comments(last: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { + nodes { + id + body + isMinimized + } + } + } + } + } +`; + const output = () => { - let body = ''; - // for each file - for (const file of inputFilenames) { - const resource_changes = JSON.parse(fs.readFileSync(file)).resource_changes; - try { - let changed_resources = resource_changes.filter((resource) => { - return resource.change.actions != ["no-op"]; - }) - - console.log("changed_resources", changed_resources) - if (Array.isArray(resource_changes) && resource_changes.length > 0) { - const resources_to_create = [], - resources_to_update = [], - resources_to_delete = [], - resources_to_replace = [], - resources_unchanged = []; - - // for each resource changes - for (const resource of resource_changes) { - const change = resource.change; - const address = resource.address; - - switch (change.actions[0]) { - default: - break; - case "no-op": - resources_unchanged.push(address); - break; - case "create": - resources_to_create.push(address); - break; - case "delete": - if (change.actions.length > 1) { - resources_to_replace.push(address); - } else { - resources_to_delete.push(address); - } - break; - case "update": - resources_to_update.push(address); - break; - } - } - // the body must be indented at the start otherwise - // there will be formatting error when comment is - // showed on GitHub - body += ` + let body = ''; + // for each file + for (const file of inputFilenames) { + const resource_changes = JSON.parse(fs.readFileSync(file)).resource_changes; + try { + let changed_resources = resource_changes.filter((resource) => { + return resource.change.actions != ["no-op"]; + }) + + console.log("changed_resources", changed_resources) + if (Array.isArray(resource_changes) && resource_changes.length > 0) { + const resources_to_create = [], + resources_to_update = [], + resources_to_delete = [], + resources_to_replace = [], + resources_unchanged = []; + + // for each resource changes + for (const resource of resource_changes) { + const change = resource.change; + const address = resource.address; + + switch (change.actions[0]) { + default: + break; + case "no-op": + resources_unchanged.push(address); + break; + case "create": + resources_to_create.push(address); + break; + case "delete": + if (change.actions.length > 1) { + resources_to_replace.push(address); + } else { + resources_to_delete.push(address); + } + break; + case "update": + resources_to_update.push(address); + break; + } + } + // the body must be indented at the start otherwise + // there will be formatting error when comment is + // showed on GitHub + body += ` ${commentHeader}
@@ -81,79 +106,135 @@ ${details("replace", resources_to_replace, "+")} ${commentFooter.map(a => a == '' ? '\n' : a).join('\n')} ${workflowLink} ` - if (resources_to_create + resources_to_delete + resources_to_update + resources_to_replace == []) { - hasNoChanges = true; - } - } else { - hasNoChanges = true; - console.log("No changes found in the plan. setting hasNoChanges to true.") - body += ` + if (resources_to_create + resources_to_delete + resources_to_update + resources_to_replace == []) { + hasNoChanges = true; + } + } else { + hasNoChanges = true; + console.log("No changes found in the plan. setting hasNoChanges to true.") + body += `

There were no changes done to the infrastructure.

` - core.info(`"The content of ${file} did not result in a valid array or the array is empty... Skipping."`) - } - } catch (error) { - core.error(`${file} is not a valid JSON file. error: ${error}`); - } + core.info(`"The content of ${file} did not result in a valid array or the array is empty... Skipping."`) + } + } catch (error) { + core.error(`${file} is not a valid JSON file. error: ${error}`); } - return body; + } + return body; } const details = (action, resources, operator) => { - let str = ""; + let str = ""; - if (resources.length !== 0) { - str = ` + if (resources.length !== 0) { + str = ` #### Resources to ${action}\n \`\`\`diff\n `; - for (const el of resources) { - // In the replace block, we show delete (-) and then create (+) - if (action === "replace") { - str += `- ${el}\n` - } - str += `${operator} ${el}\n` - } - - str += "```\n" + for (const el of resources) { + // In the replace block, we show delete (-) and then create (+) + if (action === "replace") { + str += `- ${el}\n` + } + str += `${operator} ${el}\n` } - return str; + str += "```\n" + } + + return str; } +const queryComments = (variables) => { + return octokit.graphql(commentsQuery, variables); +}; + +const minimizeComment = (variables) => { + return octokit.graphql(minimizeCommentQuery, variables); +}; + +const hideComments = () => { + core.info(`Hiding previous comments.`); + + queryComments({ + owner: context.repo.owner, + name: context.repo.repo, + number: context.issue.number + }) + .then(response => { + core.info(`Successfully retrieved comments for PR #${context.issue.number}.`); + const comments = response.repository.pullRequest.comments.nodes; + + core.info(`Found ${comments.length} comments in the PR.`); + + const filteredComments = comments.filter(comment => + comment.body.includes('Terraform Plan:') || + comment.body.includes('There were no changes done to the infrastructure.') + ); + + core.info(`Filtered down to ${filteredComments.length} comments that need to be minimized.`); + + const minimizePromises = filteredComments + .filter(comment => !comment.isMinimized) + .map(comment => { + return minimizeComment({ id: comment.id }) + .catch(error => core.error(`Failed to minimize comment ${comment.id}: ${error.message}`)); + }); + + return Promise.all(minimizePromises) + .then(() => core.info('All minimize operations completed.')) + .catch(error => core.error(`Error during minimize operations: ${error.message}`)); + }) + .catch(error => core.error(`Failed to retrieve comments: ${error.message}`)); +}; + + try { let rawOutput = output(); - if (includePlanSummary) { - core.info("Adding plan output to job summary") - core.summary.addHeading('Terraform Plan Results').addRaw(rawOutput).write() + let createComment = true; + + console.log("hidePreviousComments", hidePreviousComments) + console.log("hidePreviousComments && context.eventName === pull_request", hidePreviousComments && context.eventName === 'pull_request') + if (hidePreviousComments && context.eventName === 'pull_request') { + hideComments(); } - if (context.eventName === 'pull_request') { - core.info(`Found PR # ${context.issue.number} from workflow context - proceeding to comment.`) - } else { - core.warning("Action doesn't seem to be running in a PR workflow context.") - core.warning("Skipping comment creation.") - process.exit(0); + console.log("includePlanSummary", includePlanSummary) + if (includePlanSummary) { + core.info("Adding plan output to job summary") + core.summary.addHeading('Terraform Plan Results').addRaw(rawOutput).write() } console.log("quietMode", quietMode) console.log("hasNoChanges", hasNoChanges) console.log("quietMode && hasNoChanges", quietMode && hasNoChanges) if (quietMode && hasNoChanges) { - core.info("quiet mode is enabled and there are no changes to the infrastructure.") - core.info("Skipping comment creation.") - process.exit(0); + core.info("quiet mode is enabled and there are no changes to the infrastructure.") + core.info("Skipping comment creation.") + createComment = false } - core.info("Adding comment to PR"); - core.info(`Comment: ${rawOutput}`); + if (context.eventName === 'pull_request') { + core.info(`Found PR # ${context.issue.number} from workflow context - proceeding to comment.`) + } else { + core.info("Action doesn't seem to be running in a PR workflow context.") + core.info("Skipping comment creation.") + createComment = false + } - octokit.rest.issues.createComment({ + if (createComment) { + core.info("Adding comment to PR"); + core.info(`Comment: ${rawOutput}`); + octokit.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: rawOutput - }); + }); + core.info("Comment added successfully."); + } + } catch (error) { core.setFailed(error.message); -} +} \ No newline at end of file