rdev/internal/adapter/gitea/client.go
jordan 39df51defd feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters
Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:25:51 -07:00

227 lines
6.9 KiB
Go

// Package gitea provides a Gitea API adapter implementing port.GitRepository.
//
// Context Propagation Note:
// The Gitea Go SDK (code.gitea.io/sdk/gitea) does not natively support context
// propagation for HTTP requests. Methods accept context.Context for interface
// compatibility and future-proofing, but the underlying SDK calls do not use it
// for cancellation or timeouts. If cancellation is critical, consider using a
// context-aware HTTP transport or wrapping calls with context deadline checks.
package gitea
import (
"context"
"fmt"
"code.gitea.io/sdk/gitea"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure Client implements GitRepository.
var _ port.GitRepository = (*Client)(nil)
// Client is a Gitea API client adapter.
type Client struct {
client *gitea.Client
defaultOwner string // default organization/user for new repos
}
// SDKClient returns the underlying Gitea SDK client.
// Used by the template provider to create files in repos.
func (c *Client) SDKClient() *gitea.Client {
return c.client
}
// NewClient creates a new Gitea client.
// url is the Gitea server URL (e.g., https://git.threesix.ai)
// token is an API access token with repo permissions
// defaultOwner is the organization or user to create repos under
func NewClient(url, token, defaultOwner string) (*Client, error) {
client, err := gitea.NewClient(url, gitea.SetToken(token))
if err != nil {
return nil, fmt.Errorf("failed to create gitea client: %w", err)
}
return &Client{
client: client,
defaultOwner: defaultOwner,
}, nil
}
// CreateRepo creates a new git repository under the default owner.
func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*domain.Repo, error) {
opts := gitea.CreateRepoOption{
Name: name,
Description: description,
Private: private,
AutoInit: true,
DefaultBranch: "main",
}
var repo *gitea.Repository
var err error
// Try to create as org repo first, fall back to user repo
repo, _, err = c.client.CreateOrgRepo(c.defaultOwner, opts)
if err != nil {
// May not be an org, try as user repo
repo, _, err = c.client.CreateRepo(opts)
if err != nil {
return nil, fmt.Errorf("failed to create repo: %w", err)
}
}
return repoFromGitea(repo), nil
}
// DeleteRepo deletes a repository.
func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error {
_, err := c.client.DeleteRepo(owner, name)
if err != nil {
return fmt.Errorf("failed to delete repo %s/%s: %w", owner, name, err)
}
return nil
}
// ListRepos returns all repositories for an owner.
func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, error) {
// Try as organization first
repos, _, err := c.client.ListOrgRepos(owner, gitea.ListOrgReposOptions{
ListOptions: gitea.ListOptions{PageSize: 100},
})
if err != nil {
// Try as user
repos, _, err = c.client.ListUserRepos(owner, gitea.ListReposOptions{
ListOptions: gitea.ListOptions{PageSize: 100},
})
if err != nil {
return nil, fmt.Errorf("failed to list repos for %s: %w", owner, err)
}
}
result := make([]*domain.Repo, len(repos))
for i, r := range repos {
result[i] = repoFromGitea(r)
}
return result, nil
}
// GetRepo returns a single repository.
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) {
repo, _, err := c.client.GetRepo(owner, name)
if err != nil {
return nil, fmt.Errorf("failed to get repo %s/%s: %w", owner, name, err)
}
return repoFromGitea(repo), nil
}
// AddCollaborator adds a user as collaborator to a repo.
func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error {
var accessMode gitea.AccessMode
switch permission {
case "read":
accessMode = gitea.AccessModeRead
case "write":
accessMode = gitea.AccessModeWrite
case "admin":
accessMode = gitea.AccessModeAdmin
default:
accessMode = gitea.AccessModeRead
}
_, err := c.client.AddCollaborator(owner, repo, username, gitea.AddCollaboratorOption{
Permission: &accessMode,
})
if err != nil {
return fmt.Errorf("failed to add collaborator %s to %s/%s: %w", username, owner, repo, err)
}
return nil
}
// RemoveCollaborator removes a collaborator from a repo.
func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username string) error {
_, err := c.client.DeleteCollaborator(owner, repo, username)
if err != nil {
return fmt.Errorf("failed to remove collaborator %s from %s/%s: %w", username, owner, repo, err)
}
return nil
}
// AddDeployKey adds a deploy key to a repo.
func (c *Client) AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) (*domain.DeployKey, error) {
key, _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{
Title: title,
Key: publicKey,
ReadOnly: readOnly,
})
if err != nil {
return nil, fmt.Errorf("failed to add deploy key to %s/%s: %w", owner, repo, err)
}
return &domain.DeployKey{
ID: key.ID,
Title: key.Title,
PublicKey: key.Key,
ReadOnly: key.ReadOnly,
CreatedAt: key.Created,
}, nil
}
// DeleteDeployKey removes a deploy key from a repo.
func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID int64) error {
_, err := c.client.DeleteDeployKey(owner, repo, keyID)
if err != nil {
return fmt.Errorf("failed to delete deploy key %d from %s/%s: %w", keyID, owner, repo, err)
}
return nil
}
// CreateWebhook creates a webhook on a repository.
func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) (*domain.RepoWebhook, error) {
hook, _, err := c.client.CreateRepoHook(owner, repo, gitea.CreateHookOption{
Type: gitea.HookTypeGitea,
Config: map[string]string{
"url": url,
"content_type": "json",
"secret": secret,
},
Events: events,
Active: true,
})
if err != nil {
return nil, fmt.Errorf("failed to create webhook on %s/%s: %w", owner, repo, err)
}
return &domain.RepoWebhook{
ID: hook.ID,
URL: hook.Config["url"],
Secret: secret,
Events: hook.Events,
Active: hook.Active,
HookType: string(hook.Type),
}, nil
}
// DeleteWebhook removes a webhook from a repo.
func (c *Client) DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error {
_, err := c.client.DeleteRepoHook(owner, repo, webhookID)
if err != nil {
return fmt.Errorf("failed to delete webhook %d from %s/%s: %w", webhookID, owner, repo, err)
}
return nil
}
// repoFromGitea converts a gitea.Repository to domain.Repo.
func repoFromGitea(r *gitea.Repository) *domain.Repo {
return &domain.Repo{
ID: r.ID,
Owner: r.Owner.UserName,
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
Private: r.Private,
CloneSSH: r.SSHURL,
CloneHTTP: r.CloneURL,
HTMLURL: r.HTMLURL,
CreatedAt: r.Created,
UpdatedAt: r.Updated,
}
}