initial revision
This commit is contained in:
488
vcs.go
Normal file
488
vcs.go
Normal file
@@ -0,0 +1,488 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user