// 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 }