- 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>
186 lines
5.6 KiB
Go
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")
|
|
}
|