Files
dagger-tofu/vcs.go
2026-05-25 09:22:36 -04:00

489 lines
14 KiB
Go

// VCS (GitHub/Gitea) API integration helpers for the OpenTofu Dagger module.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"dagger/iac/internal/dagger"
)
const MARKER = "<!-- cloud-build-tofu-ci -->"
// Helper function to post commit status back to GitHub, GitHub Enterprise, or Gitea using standard HTTP.
func (m *Iac) postVCSStatus(
ctx context.Context,
token *dagger.Secret,
vcsType string, // "github", "github-enterprise" (or "ghe"), or "gitea"
vcsBaseURL string, // Base URL of the VCS server (e.g. "https://github.mycompany.com" or "https://gitea.com")
repo string, // "owner/repo"
sha string, // Commit SHA
contextName string, // Status context (e.g. "ci/plan")
state string, // "pending", "success", "failure", "error"
description string, // Status description text
targetURL string, // Optional URL link (e.g. GCB build details URL)
) error {
// Quietly bypass if VCS status variables are not configured (making local testing zero-overhead)
if token == nil || repo == "" || sha == "" {
return nil
}
tokenStr, err := token.Plaintext(ctx)
if err != nil {
return fmt.Errorf("failed to retrieve VCS token: %w", err)
}
// Clean and normalize VCS types
vcsType = strings.ToLower(strings.TrimSpace(vcsType))
vcsBaseURL = strings.TrimSuffix(strings.TrimSpace(vcsBaseURL), "/")
// 1. Construct the API URL based on VCS Type
var apiURL string
switch vcsType {
case "gitea":
if vcsBaseURL == "" {
vcsBaseURL = "https://gitea.com"
}
// Gitea API prefix is /api/v1
apiURL = fmt.Sprintf("%s/api/v1/repos/%s/statuses/%s", vcsBaseURL, repo, sha)
case "github-enterprise", "github_enterprise", "ghe":
if vcsBaseURL == "" {
return fmt.Errorf("vcsBaseURL is required when using github-enterprise")
}
// GitHub Enterprise Server API prefix is /api/v3
apiURL = fmt.Sprintf("%s/api/v3/repos/%s/statuses/%s", vcsBaseURL, repo, sha)
default: // Default to public GitHub
if vcsBaseURL == "" {
vcsBaseURL = "https://api.github.com"
}
// Public GitHub uses /repos direct endpoint
apiURL = fmt.Sprintf("%s/repos/%s/statuses/%s", vcsBaseURL, repo, sha)
}
// 2. Prepare Payload (All these platforms share the exact same JSON schema for commit statuses)
type StatusPayload struct {
State string `json:"state"`
TargetURL string `json:"target_url,omitempty"`
Description string `json:"description,omitempty"`
Context string `json:"context"`
}
payload := StatusPayload{
State: strings.ToLower(state),
TargetURL: targetURL,
Description: description,
Context: contextName,
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal status payload: %w", err)
}
// 3. Execute HTTP Request
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBytes))
if err != nil {
return fmt.Errorf("failed to create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// All three authorize using the "Authorization: token <token>" header pattern
req.Header.Set("Authorization", "token "+tokenStr)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("VCS API returned unexpected HTTP code: %s", resp.Status)
}
return nil
}
// Helper function to list issue comments to find any existing CI comment with our marker tag.
func (m *Iac) findExistingComment(
ctx context.Context,
tokenStr string,
apiURL string,
) (int, error) {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "token "+tokenStr)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, fmt.Errorf("failed to list comments: %s", resp.Status)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return 0, err
}
type Comment struct {
ID int `json:"id"`
Body string `json:"body"`
}
var comments []Comment
if err := json.Unmarshal(bodyBytes, &comments); err != nil {
return 0, err
}
// Find the first comment that contains our marker
for _, comment := range comments {
if strings.Contains(comment.Body, MARKER) {
return comment.ID, nil
}
}
return 0, nil
}
// Helper function to post or update a single consolidated PR comment containing detailed CI feedback.
func (m *Iac) postOrUpdatePRComment(
ctx context.Context,
token *dagger.Secret,
vcsType string, // "github", "github-enterprise", or "gitea"
vcsBaseURL string, // Base URL of the VCS server
repo string, // "owner/repo"
prNumber int, // Pull Request Number (e.g. 42)
body string, // Comment body in Markdown
) error {
// Quietly bypass if VCS commenting parameters are not fully set
if token == nil || repo == "" || prNumber <= 0 || body == "" {
return nil
}
tokenStr, err := token.Plaintext(ctx)
if err != nil {
return fmt.Errorf("failed to retrieve VCS token for PR comment: %w", err)
}
// Normalize VCS types
vcsType = strings.ToLower(strings.TrimSpace(vcsType))
vcsBaseURL = strings.TrimSuffix(strings.TrimSpace(vcsBaseURL), "/")
// 1. Determine Endpoint URLs
var commentsURL string
var patchURL string
switch vcsType {
case "gitea":
if vcsBaseURL == "" {
vcsBaseURL = "https://gitea.com"
}
commentsURL = fmt.Sprintf("%s/api/v1/repos/%s/issues/%d/comments", vcsBaseURL, repo, prNumber)
patchURL = fmt.Sprintf("%s/api/v1/repos/%s/issues/comments", vcsBaseURL, repo)
case "github-enterprise", "github_enterprise", "ghe":
if vcsBaseURL == "" {
return fmt.Errorf("vcsBaseURL is required when using github-enterprise")
}
commentsURL = fmt.Sprintf("%s/api/v3/repos/%s/issues/%d/comments", vcsBaseURL, repo, prNumber)
patchURL = fmt.Sprintf("%s/api/v3/repos/%s/issues/comments", vcsBaseURL, repo)
default: // Public GitHub
if vcsBaseURL == "" {
vcsBaseURL = "https://api.github.com"
}
commentsURL = fmt.Sprintf("%s/repos/%s/issues/%d/comments", vcsBaseURL, repo, prNumber)
patchURL = fmt.Sprintf("%s/repos/%s/issues/comments", vcsBaseURL)
}
// 2. Search for an existing comment with our marker tag
existingID, err := m.findExistingComment(ctx, tokenStr, commentsURL)
if err != nil {
fmt.Printf("Warning: Failed to search for existing comments: %v\n", err)
}
// 3. Prepare JSON Payload
type CommentPayload struct {
Body string `json:"body"`
}
payload := CommentPayload{
Body: body,
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal comment payload: %w", err)
}
// 4. Create or Update Comment via standard HTTP APIs
var req *http.Request
if existingID > 0 {
// Update existing comment using PATCH
apiURL := fmt.Sprintf("%s/%d", patchURL, existingID)
req, err = http.NewRequestWithContext(ctx, "PATCH", apiURL, bytes.NewBuffer(jsonBytes))
} else {
// Post a brand-new comment using POST
req, err = http.NewRequestWithContext(ctx, "POST", commentsURL, bytes.NewBuffer(jsonBytes))
}
if err != nil {
return fmt.Errorf("failed to create comment request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+tokenStr)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("comment HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("comment API returned error code %s", resp.Status)
}
return nil
}
// Return status badge matching the original google-cloud-cli format.
func (m *Iac) statusBadge(code string) string {
switch code {
case "0", "2":
return "✅ `success`"
case "skipped":
return "⏭ `skipped`"
default:
return "❌ `failure`"
}
}
// Clean ANSI colors from captured stdout
func (m *Iac) stripAnsi(text string) string {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
return ansiRegex.ReplaceAllString(text, "")
}
// Generate the failure details block (only outputs when step failed and has stdout)
func (m *Iac) failureDetails(label string, code string, output string) string {
if code == "0" || code == "skipped" {
return ""
}
cleanOutput := m.stripAnsi(output)
if cleanOutput == "" {
return ""
}
return fmt.Sprintf("<details><summary>%s output</summary>\n\n```\n%s\n```\n</details>\n", label, cleanOutput)
}
// Format the OpenTofu plan block matching the exact regex transformations of the shell script.
func (m *Iac) planBlock(code string, rawOutput string) string {
if code == "skipped" || rawOutput == "" {
return ""
}
clean := m.stripAnsi(rawOutput)
if code == "0" || code == "2" {
// Match the sed logic: Keep from the execution plan header onward
headers := []string{
"An execution plan has been generated and is shown below.",
"Terraform used the selected providers to generate the following execution",
"OpenTofu used the selected providers to generate the following execution",
"No changes. Infrastructure is up-to-date.",
"No changes. Your infrastructure matches the configuration.",
"Note: Objects have changed outside of Terraform",
}
firstIdx := -1
for _, h := range headers {
idx := strings.Index(clean, h)
if idx != -1 && (firstIdx == -1 || idx < firstIdx) {
firstIdx = idx
}
}
if firstIdx != -1 {
clean = clean[firstIdx:]
}
// Match the sed logic: Truncate at the plan summary line ("Plan: ...")
if idx := strings.Index(clean, "Plan: "); idx != -1 {
lineEnd := strings.Index(clean[idx:], "\n")
if lineEnd != -1 {
clean = clean[:idx+lineEnd]
}
}
// Truncate if too large for Gitea/GitHub comments
if len(clean) > 65000 {
clean = clean[:65000] + "\n...(truncated due to size limits)"
}
// Match the sed logic: Move diff indicators (+, -, ~) to the very start of lines for highlight coloring
lines := strings.Split(clean, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 0 {
firstChar := trimmed[0]
if firstChar == '-' || firstChar == '+' || firstChar == '~' {
if firstChar == '~' {
firstChar = '!'
}
rest := strings.TrimSpace(trimmed[1:])
lines[i] = string(firstChar) + " " + rest
}
}
}
clean = strings.Join(lines, "\n")
return fmt.Sprintf("<details open><summary>Show Plan</summary>\n\n```diff\n%s\n```\n</details>\n", clean)
}
// Failed plan block format
return fmt.Sprintf("<details><summary>Plan output</summary>\n\n```\n%s\n```\n</details>\n", clean)
}
// Assemble the single consolidated PR comment body mapping exactly to the shell script wrapper format.
func (m *Iac) buildConsolidatedPRComment(
tflintCode, fmtCode, validateCode, planCode string,
tflintOut, fmtOut, validateOut, planOut string,
buildID, buildURL string,
) string {
tflintBadge := m.statusBadge(tflintCode)
fmtBadge := m.statusBadge(fmtCode)
validateBadge := m.statusBadge(validateCode)
planBadge := m.statusBadge(planCode)
tflintDetails := m.failureDetails("TFLint", tflintCode, tflintOut)
fmtDetails := m.failureDetails("Format", fmtCode, fmtOut)
validateDetails := m.failureDetails("Validate", validateCode, validateOut)
planDetails := m.planBlock(planCode, planOut)
return fmt.Sprintf(`%s
#### TFLint %s
%s
#### TF Format %s
%s
#### TF Validate %s
%s
#### TF Plan %s
%s
*Build: [%s](%s)*`,
MARKER,
tflintBadge, tflintDetails,
fmtBadge, fmtDetails,
validateBadge, validateDetails,
planBadge, planDetails,
buildID, buildURL,
)
}
// Helper to scan OpenTofu logs and extract the summary line (e.g. "Plan: 5 to add, 0 to change, 2 to destroy.")
func (m *Iac) extractPlanSummary(stdout string) string {
lines := strings.Split(stdout, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Plan:") {
return trimmed
}
if strings.Contains(trimmed, "No changes. Your infrastructure matches the configuration.") || strings.Contains(trimmed, "No changes.") {
return "Plan: No changes. Your infrastructure matches your configuration perfectly! 🎉"
}
}
return "Plan summary line could not be parsed."
}
// Helper to construct a beautiful Markdown feedback comment for the Plan stage.
func (m *Iac) buildPlanComment(repo string, sha string, stdout string, executionErr error) string {
statusEmoji := "✅"
statusText := "Succeeded"
summary := m.extractPlanSummary(stdout)
if executionErr != nil {
statusEmoji = "❌"
statusText = "Failed"
summary = "OpenTofu execution failed. Please check the logs inside GCB or detailed view below."
}
var rawLogs string
if stdout != "" {
rawLogs = fmt.Sprintf("```terraform\n%s\n```", stdout)
} else {
rawLogs = "*(No log output was captured)*"
}
return fmt.Sprintf(`### 🤖 OpenTofu Speculative Plan Results %s
| Property | Details |
| :--- | :--- |
| **VCS Repository** | %s |
| **Git Commit SHA** | %s |
| **Plan Status** | %s **%s** |
| **Execution Summary** | **%s** |
<br>
<details>
<summary>🔍 Click to Expand Detailed OpenTofu Plan logs</summary>
%s
</details>
<br>
*Feedback posted automatically via [Dagger.io](https://dagger.io).*`, statusEmoji, repo, sha, statusEmoji, statusText, summary, rawLogs)
}
// Helper to construct a beautiful Markdown feedback comment for the Validate stage.
func (m *Iac) buildValidationComment(repo string, sha string, stdout string, executionErr error) string {
statusEmoji := "✅"
statusText := "Passed"
summary := "OpenTofu configuration structure and variables are 100% valid."
if executionErr != nil {
statusEmoji = "❌"
statusText = "Failed"
summary = "OpenTofu static validation checks failed. See syntax errors below."
}
var rawLogs string
if stdout != "" {
rawLogs = fmt.Sprintf("```terraform\n%s\n```", stdout)
} else {
rawLogs = "*(No validation logs recorded)*"
}
return fmt.Sprintf(`### 🔍 OpenTofu Static Validation Results %s
| Property | Details |
| :--- | :--- |
| **VCS Repository** | %s |
| **Git Commit SHA** | %s |
| **Validation Status** | %s **%s** |
| **Validation Details** | **%s** |
<br>
<details>
<summary>🔍 Click to Expand Validation Details</summary>
%s
</details>
<br>
*Feedback posted automatically via [Dagger.io](https://dagger.io).*`, statusEmoji, repo, sha, statusEmoji, statusText, summary, rawLogs)
}