rdev/internal/adapter/gitea/bulk_files.go
jordan 34e72687e6 feat: Complete automation gaps for repeatable project deployments
- Initial K8s deployment auto-creation during project creation
- DNS record upsert support (create or update existing records)
- Ingress host management for domain aliases (AddIngressHost/RemoveIngressHost)
- Woodpecker deployer RBAC manifest for CI deploy steps
- Single-commit template seeding via Gitea bulk file API

Closes automation gaps exposed during www.threesix.ai launch:
- Projects now auto-create K8s Deployment/Service/Ingress on creation
- Domain aliases automatically update both DNS and K8s ingress
- CI deploy steps work without manual RBAC setup
- Template seeding triggers only one CI pipeline (not per-file)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:18:31 -07:00

186 lines
5.6 KiB
Go

// Package gitea provides a Gitea API adapter implementing port.GitRepository.
package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ChangeFileOperation represents a single file operation in a bulk change.
type ChangeFileOperation struct {
// Operation is "create", "update", or "delete"
Operation string `json:"operation"`
// Path is the file path (max 500 characters)
Path string `json:"path"`
// Content is the base64-encoded file content (required for create/update)
Content string `json:"content,omitempty"`
// SHA is required for update and delete operations
SHA string `json:"sha,omitempty"`
// FromPath is the original path when moving/renaming files
FromPath string `json:"from_path,omitempty"`
}
// ChangeFilesOptions options for changing multiple files in a single commit.
type ChangeFilesOptions struct {
// Files is the list of file operations
Files []ChangeFileOperation `json:"files"`
// Message is the commit message
Message string `json:"message,omitempty"`
// BranchName is the branch to commit to (optional, defaults to repo default)
BranchName string `json:"branch,omitempty"`
// NewBranchName creates a new branch (optional)
NewBranchName string `json:"new_branch,omitempty"`
}
// FilesResponse is the response from bulk file operations.
type FilesResponse struct {
Files []FileContentResponse `json:"files"`
Commit *FileCommitResponse `json:"commit"`
}
// FileContentResponse represents a file in the response.
type FileContentResponse struct {
Name string `json:"name"`
Path string `json:"path"`
SHA string `json:"sha"`
Size int64 `json:"size"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
GitURL string `json:"git_url"`
DownloadURL string `json:"download_url"`
Type string `json:"type"`
}
// FileCommitResponse contains commit information from the response.
type FileCommitResponse struct {
SHA string `json:"sha"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
Author *User `json:"author"`
Committer *User `json:"committer"`
Message string `json:"message"`
}
// User represents a git user in commit info.
type User struct {
Name string `json:"name"`
Email string `json:"email"`
}
// BulkFileClient wraps a Gitea client with direct HTTP capabilities
// for operations not yet supported by the SDK.
type BulkFileClient struct {
baseURL string
token string
client *http.Client
}
// NewBulkFileClient creates a new client for bulk file operations.
// baseURL is the Gitea server URL (e.g., https://git.threesix.ai)
// token is an API access token with repo permissions
func NewBulkFileClient(baseURL, token string) *BulkFileClient {
return &BulkFileClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
token: token,
client: &http.Client{},
}
}
// ChangeFiles creates, updates, or deletes multiple files in a single commit.
// This uses the Gitea API endpoint POST /repos/{owner}/{repo}/contents
// which was added in Gitea v1.20.0 (PR #24887).
// Includes retry with exponential backoff for transient failures.
func (c *BulkFileClient) ChangeFiles(ctx context.Context, owner, repo string, opts ChangeFilesOptions) (*FilesResponse, error) {
const maxRetries = 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
result, err := c.doChangeFiles(ctx, owner, repo, opts)
if err == nil {
return result, nil
}
lastErr = err
// Don't retry client errors (4xx) except rate limiting (429)
if !isRetryableError(err) {
return nil, err
}
// Wait before retry with exponential backoff
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt+1) * time.Second):
}
}
return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr)
}
// doChangeFiles performs the actual API request.
func (c *BulkFileClient) doChangeFiles(ctx context.Context, owner, repo string, opts ChangeFilesOptions) (*FilesResponse, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, owner, repo)
body, err := json.Marshal(&opts)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &apiError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
var result FilesResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
// apiError represents an API error with status code.
type apiError struct {
StatusCode int
Body string
}
func (e *apiError) Error() string {
return fmt.Sprintf("API error (status %d): %s", e.StatusCode, e.Body)
}
// isRetryableError checks if an error should be retried.
func isRetryableError(err error) bool {
if apiErr, ok := err.(*apiError); ok {
// Retry server errors (5xx) and rate limiting (429)
return apiErr.StatusCode >= 500 || apiErr.StatusCode == 429
}
// Retry network errors
return strings.Contains(err.Error(), "connection") ||
strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "EOF")
}