rdev/internal/adapter/gitea/bulk_files.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

239 lines
7.2 KiB
Go

// Package gitea provides a Gitea API adapter implementing port.GitRepository.
package gitea
import (
"bytes"
"context"
"encoding/base64"
"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{
Timeout: 30 * time.Second,
},
}
}
// 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")
}
// GetFileContent retrieves the content of a file from a repository.
// Returns the decoded content and the file's SHA (needed for updates).
// Returns nil, nil if the file doesn't exist (404).
func (c *BulkFileClient) GetFileContent(ctx context.Context, owner, repo, filepath string) ([]byte, string, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to create request: %w", err)
}
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 == 404 {
return nil, "", nil // File doesn't exist
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", &apiError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
var result struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
SHA string `json:"sha"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, "", fmt.Errorf("failed to parse response: %w", err)
}
// Decode base64 content
content, err := base64.StdEncoding.DecodeString(result.Content)
if err != nil {
return nil, "", fmt.Errorf("failed to decode content: %w", err)
}
return content, result.SHA, nil
}