#!/usr/bin/env python3 """ CI Plan Comment Bot for Cloudflare GitOps Phase 6 - PR Workflows Posts Terraform plan summaries as comments on Merge Requests. Designed to run in GitLab CI/CD pipelines. """ import json import os import subprocess import sys from pathlib import Path from typing import Any, Dict, Optional try: import requests import yaml except ImportError: print("ERROR: pip install requests pyyaml", file=sys.stderr) sys.exit(1) HERE = Path(__file__).resolve().parent CONFIG_PATH = HERE / "config.yml" def load_config() -> Dict[str, Any]: """Load gitops configuration with env expansion""" with open(CONFIG_PATH) as f: config = yaml.safe_load(f) def expand_env(obj): if isinstance(obj, str): if obj.startswith("${") and "}" in obj: inner = obj[2:obj.index("}")] default = None var = inner if ":-" in inner: var, default = inner.split(":-", 1) return os.environ.get(var, default) return obj elif isinstance(obj, dict): return {k: expand_env(v) for k, v in obj.items()} elif isinstance(obj, list): return [expand_env(i) for i in obj] return obj return expand_env(config) def get_plan_summary() -> tuple[str, Dict]: """Run plan_summarizer and get both formats""" # Markdown for comment result = subprocess.run( ["python3", "plan_summarizer.py", "--format", "markdown"], cwd=HERE, capture_output=True, text=True, check=True, ) markdown = result.stdout # JSON for processing result = subprocess.run( ["python3", "plan_summarizer.py", "--format", "json"], cwd=HERE, capture_output=True, text=True, check=True, ) summary_json = json.loads(result.stdout) return markdown, summary_json class GitLabCI: """GitLab CI integration""" def __init__(self, token: str): self.base_url = os.environ.get("CI_API_V4_URL", "https://gitlab.com/api/v4") self.project_id = os.environ.get("CI_PROJECT_ID") self.mr_iid = os.environ.get("CI_MERGE_REQUEST_IID") self.commit_sha = os.environ.get("CI_COMMIT_SHA", "")[:8] self.pipeline_url = os.environ.get("CI_PIPELINE_URL", "") self.job_name = os.environ.get("CI_JOB_NAME", "terraform-plan") self.token = token self.headers = {"PRIVATE-TOKEN": token} @property def is_mr_pipeline(self) -> bool: return bool(self.mr_iid) def get_existing_comments(self) -> list: """Get existing MR comments""" url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes" resp = requests.get(url, headers=self.headers) resp.raise_for_status() return resp.json() def find_bot_comment(self, marker: str) -> Optional[Dict]: """Find existing bot comment by marker""" comments = self.get_existing_comments() for comment in comments: if marker in comment.get("body", ""): return comment return None def post_comment(self, body: str) -> Dict: """Post a new comment on the MR""" url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes" resp = requests.post(url, headers=self.headers, data={"body": body}) resp.raise_for_status() return resp.json() def update_comment(self, note_id: int, body: str) -> Dict: """Update an existing comment""" url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes/{note_id}" resp = requests.put(url, headers=self.headers, data={"body": body}) resp.raise_for_status() return resp.json() def delete_comment(self, note_id: int): """Delete a comment""" url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes/{note_id}" resp = requests.delete(url, headers=self.headers) resp.raise_for_status() class GitHubActions: """GitHub Actions integration""" def __init__(self, token: str): self.base_url = "https://api.github.com" self.repo = os.environ.get("GITHUB_REPOSITORY", "") self.pr_number = self._get_pr_number() self.commit_sha = os.environ.get("GITHUB_SHA", "")[:8] self.run_url = f"https://github.com/{self.repo}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}" self.token = token self.headers = { "Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json", } def _get_pr_number(self) -> Optional[str]: """Extract PR number from GitHub event""" event_path = os.environ.get("GITHUB_EVENT_PATH") if event_path and os.path.exists(event_path): with open(event_path) as f: event = json.load(f) pr = event.get("pull_request", {}) return str(pr.get("number", "")) if pr else None return None @property def is_pr_pipeline(self) -> bool: return bool(self.pr_number) def find_bot_comment(self, marker: str) -> Optional[Dict]: """Find existing bot comment""" url = f"{self.base_url}/repos/{self.repo}/issues/{self.pr_number}/comments" resp = requests.get(url, headers=self.headers) resp.raise_for_status() for comment in resp.json(): if marker in comment.get("body", ""): return comment return None def post_comment(self, body: str) -> Dict: """Post a new comment""" url = f"{self.base_url}/repos/{self.repo}/issues/{self.pr_number}/comments" resp = requests.post(url, headers=self.headers, json={"body": body}) resp.raise_for_status() return resp.json() def update_comment(self, comment_id: int, body: str) -> Dict: """Update existing comment""" url = f"{self.base_url}/repos/{self.repo}/issues/comments/{comment_id}" resp = requests.patch(url, headers=self.headers, json={"body": body}) resp.raise_for_status() return resp.json() def build_comment_body( cfg: Dict[str, Any], summary_md: str, summary_json: Dict, ci_info: Dict, ) -> str: """Build the full comment body""" ci_cfg = cfg.get("ci", {}) header = ci_cfg.get("comment_header", "Terraform Plan Summary") # Risk indicator risk = summary_json.get("overall_risk", "UNKNOWN") risk_emoji = { "LOW": "🟢", "MEDIUM": "🟡", "HIGH": "🟠", "CRITICAL": "🔴", }.get(risk, "⚪") # Marker for finding/updating this comment marker = "" changes = summary_json.get("total_changes", 0) compliance = summary_json.get("compliance_violations", []) # Build body lines = [ marker, f"# {risk_emoji} {header}", "", f"**Commit:** `{ci_info.get('commit_sha', 'N/A')}`", f"**Pipeline:** [{ci_info.get('job_name', 'terraform-plan')}]({ci_info.get('pipeline_url', '#')})", "", ] # Compliance warning banner if compliance: frameworks = ", ".join(compliance) lines.extend([ f"> ⚠️ **Compliance Impact:** {frameworks}", "", ]) # No changes case if changes == 0: lines.extend([ "✅ **No changes detected.**", "", "Terraform state matches the current configuration.", ]) else: # Add summary lines.append(summary_md) # Add approval reminder for high risk if risk in ("HIGH", "CRITICAL"): lines.extend([ "", "---", f"⚠️ **{risk} risk changes detected.** Additional review recommended.", ]) lines.extend([ "", "---", f"*Last updated: {ci_info.get('timestamp', 'N/A')} • Phase 6 GitOps*", ]) return "\n".join(lines) def main(): """Main entry point""" import argparse from datetime import datetime parser = argparse.ArgumentParser( description="Post terraform plan comment on MR" ) parser.add_argument( "--dry-run", action="store_true", help="Print comment but don't post", ) parser.add_argument( "--update", action="store_true", default=True, help="Update existing comment instead of creating new one", ) args = parser.parse_args() # Load config cfg = load_config() # Detect CI platform token = os.environ.get("GITLAB_TOKEN") or os.environ.get("GITHUB_TOKEN") if not token: print("ERROR: GITLAB_TOKEN or GITHUB_TOKEN required", file=sys.stderr) sys.exit(1) # Determine platform if os.environ.get("GITLAB_CI"): ci = GitLabCI(token) platform = "gitlab" elif os.environ.get("GITHUB_ACTIONS"): ci = GitHubActions(token) platform = "github" else: print("ERROR: Must run in GitLab CI or GitHub Actions", file=sys.stderr) sys.exit(1) # Check if this is an MR/PR pipeline if not ci.is_mr_pipeline and not ci.is_pr_pipeline: print("Not an MR/PR pipeline. Skipping comment.") return # Get plan summary print("Getting plan summary...") summary_md, summary_json = get_plan_summary() # Build CI info ci_info = { "commit_sha": getattr(ci, "commit_sha", ""), "pipeline_url": getattr(ci, "pipeline_url", "") or getattr(ci, "run_url", ""), "job_name": getattr(ci, "job_name", "terraform-plan"), "timestamp": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), } # Build comment body = build_comment_body(cfg, summary_md, summary_json, ci_info) if args.dry_run: print("\n" + "=" * 60) print("[DRY RUN] Would post comment:") print("=" * 60) print(body) return # Find existing comment to update marker = "" existing = ci.find_bot_comment(marker) if existing and args.update: print(f"Updating existing comment {existing.get('id') or existing.get('note_id')}...") note_id = existing.get("id") or existing.get("note_id") ci.update_comment(note_id, body) print("Comment updated.") else: print("Posting new comment...") result = ci.post_comment(body) print(f"Comment posted: {result.get('id') or result.get('html_url')}") # Output for CI risk = summary_json.get("overall_risk", "UNKNOWN") changes = summary_json.get("total_changes", 0) print(f"\nSummary: {changes} changes, {risk} risk") # Set CI output variables (for use in subsequent jobs) if os.environ.get("GITHUB_OUTPUT"): with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"risk_level={risk}\n") f.write(f"change_count={changes}\n") elif os.environ.get("GITLAB_CI"): # GitLab: write to dotenv artifact with open("plan_output.env", "w") as f: f.write(f"PLAN_RISK_LEVEL={risk}\n") f.write(f"PLAN_CHANGE_COUNT={changes}\n") if __name__ == "__main__": main()