489 lines
14 KiB
Go
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)
|
|
}
|