Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance PR comments #308

Merged
merged 8 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/test-apply.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1154,3 +1154,41 @@ jobs:
echo "::error:: run_id should not be set"
exit 1
fi

long_outputs:
runs-on: ubuntu-latest
name: Apply a plan with long outputs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Plan
uses: ./terraform-plan
with:
path: tests/workflows/test-apply/long_outputs

- name: Apply
uses: ./terraform-apply
with:
path: tests/workflows/test-apply/long_outputs

outputs:
runs-on: ubuntu-latest
name: Apply a plan with outputs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Plan
uses: ./terraform-plan
with:
path: tests/workflows/test-apply/outputs

- name: Apply
uses: ./terraform-apply
with:
path: tests/workflows/test-apply/outputs
33 changes: 33 additions & 0 deletions .github/workflows/test-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,36 @@ jobs:
with:
path: tests/workflows/test-plan/plan
label: arm64

always_new:
runs-on: ubuntu-latest
name: always-new
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Plan with label
uses: ./terraform-plan
with:
path: tests/workflows/test-plan/always-new
label: always-new SHOULD BE OUTDATED

- name: Plan 2 with label
uses: ./terraform-plan
with:
path: tests/workflows/test-plan/always-new
label: always-new SHOULD BE OUTDATED
add_github_comment: always-new

- name: Plan
uses: ./terraform-plan
with:
path: tests/workflows/test-plan/always-new

- name: Plan 2
uses: ./terraform-plan
with:
path: tests/workflows/test-plan/always-new
add_github_comment: always-new
12 changes: 1 addition & 11 deletions image/actions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -368,17 +368,7 @@ function set-remote-plan-args() {

function output() {
debug_log $TOOL_COMMAND_NAME output -json
(cd "$INPUT_PATH" && $TOOL_COMMAND_NAME output -json | convert_output)
}

function update_status() {
local status="$1"

if ! STATUS="$status" github_pr_comment status 2>"$STEP_TMP_DIR/github_pr_comment.stderr"; then
debug_file "$STEP_TMP_DIR/github_pr_comment.stderr"
else
debug_file "$STEP_TMP_DIR/github_pr_comment.stderr"
fi
(cd "$INPUT_PATH" && $TOOL_COMMAND_NAME output -json | tee "$STEP_TMP_DIR/terraform_output.json" | convert_output)
}

function random_string() {
Expand Down
18 changes: 13 additions & 5 deletions image/entrypoints/apply.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ set-plan-args

PLAN_OUT="$STEP_TMP_DIR/plan.out"

function update_comment() {
if ! github_pr_comment "$@" 2>"$STEP_TMP_DIR/github_pr_comment.stderr"; then
debug_file "$STEP_TMP_DIR/github_pr_comment.stderr"
else
debug_file "$STEP_TMP_DIR/github_pr_comment.stderr"
fi
}

if [[ -v TERRAFORM_ACTIONS_GITHUB_TOKEN ]]; then
update_status ":orange_circle: Applying plan in $(job_markdown_ref)"
update_comment begin-apply
fi

exec 3>&1
Expand Down Expand Up @@ -54,14 +62,15 @@ function apply() {
set -e

if [[ $APPLY_EXIT -eq 0 ]]; then
update_status ":white_check_mark: Plan applied in $(job_markdown_ref)"
output
update_comment apply-complete "$STEP_TMP_DIR/terraform_output.json"
else
if lock-info "$STEP_TMP_DIR/terraform_apply.stderr"; then
set_output failure-reason state-locked
else
set_output failure-reason apply-failed
fi
update_status ":x: Error applying plan in $(job_markdown_ref)"
update_comment error
exit 1
fi
}
Expand Down Expand Up @@ -91,7 +100,7 @@ if [[ $PLAN_EXIT -eq 1 ]]; then
set_output failure-reason state-locked
fi

update_status ":x: Error applying plan in $(job_markdown_ref)"
update_comment error
exit 1
fi

Expand Down Expand Up @@ -141,4 +150,3 @@ else

fi

output
2 changes: 1 addition & 1 deletion image/entrypoints/plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ if [[ -z "$PLAN_OUT" ]]; then
fi

if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "issue_comment" || "$GITHUB_EVENT_NAME" == "pull_request_review_comment" || "$GITHUB_EVENT_NAME" == "pull_request_target" || "$GITHUB_EVENT_NAME" == "pull_request_review" || "$GITHUB_EVENT_NAME" == "repository_dispatch" ]]; then
if [[ "$INPUT_ADD_GITHUB_COMMENT" == "true" || "$INPUT_ADD_GITHUB_COMMENT" == "changes-only" ]]; then
if [[ "$INPUT_ADD_GITHUB_COMMENT" == "true" || "$INPUT_ADD_GITHUB_COMMENT" == "changes-only" || "$INPUT_ADD_GITHUB_COMMENT" == "always-new" ]]; then

if [[ ! -v TERRAFORM_ACTIONS_GITHUB_TOKEN ]]; then
echo "GITHUB_TOKEN environment variable must be set to add GitHub PR comments"
Expand Down
151 changes: 133 additions & 18 deletions image/src/github_pr_comment/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import json
import os
import subprocess
import re
Expand All @@ -20,6 +21,7 @@
from github_pr_comment.cmp import plan_cmp, remove_warnings, remove_unchanged_attributes
from github_pr_comment.comment import find_comment, TerraformComment, update_comment, serialize, deserialize
from github_pr_comment.hash import comment_hash, plan_hash
from plan_renderer.outputs import render_outputs
from plan_renderer.variables import render_argument_list, Sensitive
from terraform.module import load_module, get_sensitive_variables
from terraform import hcl
Expand Down Expand Up @@ -286,6 +288,47 @@ def get_pr() -> PrUrl:

return cast(PrUrl, pr_url)

def new_pr_comment(backend_fingerprint: bytes) -> TerraformComment:
pr_url = get_pr()
issue_url = get_issue_url(pr_url)

headers = {
'workspace': os.environ.get('INPUT_WORKSPACE', 'default'),
}

if backend_type := os.environ.get('TERRAFORM_BACKEND_TYPE'):
if backend_type == 'cloud':
backend_type = 'remote'
headers['backend_type'] = backend_type

headers['label'] = os.environ.get('INPUT_LABEL') or None

plan_modifier = {}
if target := os.environ.get('INPUT_TARGET'):
plan_modifier['target'] = sorted(t.strip() for t in target.replace(',', '\n', ).split('\n') if t.strip())

if replace := os.environ.get('INPUT_REPLACE'):
plan_modifier['replace'] = sorted(t.strip() for t in replace.replace(',', '\n', ).split('\n') if t.strip())

if os.environ.get('INPUT_DESTROY') == 'true':
plan_modifier['destroy'] = 'true'

if plan_modifier:
debug(f'Plan modifier: {plan_modifier}')
headers['plan_modifier'] = hashlib.sha256(canonicaljson.encode_canonical_json(plan_modifier)).hexdigest()

headers['backend'] = comment_hash(backend_fingerprint, pr_url)

return TerraformComment(
issue_url=issue_url,
comment_url=None,
headers={k: v for k, v in headers.items() if v is not None},
description='',
summary='',
body='',
status=''
)

def get_comment(action_inputs: PlanPrInputs, backend_fingerprint: bytes, backup_fingerprint: bytes) -> TerraformComment:
if 'comment' in step_cache:
return deserialize(step_cache['comment'])
Expand All @@ -298,6 +341,7 @@ def get_comment(action_inputs: PlanPrInputs, backend_fingerprint: bytes, backup_

headers = {
'workspace': os.environ.get('INPUT_WORKSPACE', 'default'),
'closed': None
}

if backend_type := os.environ.get('TERRAFORM_BACKEND_TYPE'):
Expand Down Expand Up @@ -337,41 +381,73 @@ def is_approved(proposed_plan: str, comment: TerraformComment) -> bool:
debug('Approving plan based on plan text')
return plan_cmp(proposed_plan, comment.body)

def truncate(text: str, max_size: int, too_big_message: str) -> str:
lines = []
total_size = 0

for line in text.splitlines():
line_size = len(line.encode()) + 1 # + newline
if total_size + line_size > max_size:
lines.append(too_big_message)
break

lines.append(line)
total_size += line_size

return '\n'.join(lines)

def format_plan_text(plan_text: str) -> Tuple[str, str]:
"""
Format the given plan for insertion into a PR comment
"""

max_body_size = 50000 # bytes

def truncate(t):
lines = []
total_size = 0

for line in t.splitlines():
line_size = len(line.encode()) + 1 # + newline
if total_size + line_size > max_body_size:
lines.append('Plan is too large to fit in a PR comment. See the full plan in the workflow log.')
break

lines.append(line)
total_size += line_size

return '\n'.join(lines)

if len(plan_text.encode()) > max_body_size:
# needs truncation
return 'trunc', truncate(plan_text)
return 'trunc', truncate(plan_text, max_body_size, 'Plan is too large to fit in a PR comment. See the full plan in the workflow log.')
else:
return 'text', plan_text

def format_output_status(outputs: Optional[dict], remaining_size: int) -> str:
status = f':white_check_mark: Plan applied in {job_markdown_ref()}'
stripped_output = render_outputs(outputs).strip()

if stripped_output:
if len(stripped_output) > remaining_size:
stripped_output = truncate(stripped_output, remaining_size, 'Outputs are too large to fit in a PR comment. See the full outputs in the workflow log.')

open_att = ' open' if len(stripped_output.splitlines()) < 6 else ''

status += f'''\n<details{open_att}><summary>Outputs</summary>

```hcl
{stripped_output}
```
</details>
'''

return status

def read_outputs(path: str) -> Optional[dict]:
try:
with open(path, 'r') as f:
return json.load(f)
except Exception as e:
debug(f'Failed to read terraform outputs from {path}')
debug(str(e))
return None

def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write(f'''Usage:
STATUS="<status>" {sys.argv[0]} plan <plan.txt
STATUS="<status>" {sys.argv[0]} status
{sys.argv[0]} get plan.txt
{sys.argv[0]} approved plan.txt
{sys.argv[0]} begin-apply
{sys.argv[0]} error
{sys.argv[0]} apply-complete <OUTPUTS_JSON_FILE>
{sys.argv[0]} get <PLAN_TEXT_FILE>
{sys.argv[0]} approved <PLAN_TEXT_FILE>
''')
return 1

Expand Down Expand Up @@ -399,6 +475,20 @@ def main() -> int:
if action_inputs['INPUT_ADD_GITHUB_COMMENT'] == 'changes-only' and os.environ.get('TF_CHANGES', 'true') == 'false':
only_if_exists = True

if action_inputs['INPUT_ADD_GITHUB_COMMENT'] == 'always-new' and comment.comment_url is not None:
# Close the existing comment
debug('Closing existing comment')
update_comment(
github,
comment,
summary=f'<strike>{comment.summary}</strike>',
headers=comment.headers | {'closed': True},
status=':spider_web: Plan is outdated'
)

# Create the replacement comment
comment = new_pr_comment(backend_fingerprint)

if comment.comment_url is None and only_if_exists:
debug('Comment doesn\'t already exist - not creating it')
return 0
Expand Down Expand Up @@ -427,6 +517,31 @@ def main() -> int:
else:
comment = update_comment(github, comment, status=status)

elif sys.argv[1] == 'begin-apply':
if comment.comment_url is None:
debug("Can't set status of comment that doesn't exist")
return 1
else:
comment = update_comment(github, comment, status=f':orange_circle: Applying plan in {job_markdown_ref()}')

elif sys.argv[1] == 'error':
if comment.comment_url is None:
debug("Can't set status of comment that doesn't exist")
return 1
else:
comment = update_comment(github, comment, headers=comment.headers | {'closed': True}, status=f':x: Error applying plan in {job_markdown_ref()}')

elif sys.argv[1] == 'apply-complete':
if comment.comment_url is None:
debug("Can't set status of comment that doesn't exist")
return 1
else:
outputs = read_outputs(sys.argv[2])

remaining_size = 55000 - len(comment.body)

comment = update_comment(github, comment, headers=comment.headers | {'closed': True}, status=format_output_status(outputs, remaining_size))

elif sys.argv[1] == 'get':
if comment.comment_url is None:
debug("Can't get the plan from comment that doesn't exist")
Expand Down
1 change: 1 addition & 0 deletions image/src/github_pr_comment/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class TerraformComment:
'plan_hash', # A deterministic hash of the plan (without warnings or unchanged attributes, eventually with unmasked variables)
'variables_hash', # A hash of input variables and values
'truncated' # If the plan text has been truncated (should not be used to approve plans, and will not show a complete diff)
'closed' # If the comment has been closed for modifications
]

"""
Expand Down
9 changes: 9 additions & 0 deletions image/src/plan_renderer/outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Any

from plan_renderer.variables import render_argument_list, Sensitive


def render_outputs(outputs: dict[str, Any]) -> str:
return render_argument_list({
key: Sensitive() if value['sensitive'] else value['value'] for key, value in outputs.items()
})
Loading
Loading