// 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. // // TODO: Fix Gitea ALLOWED_HOST_LIST — set to "private,loopback" in Gitea app.ini // to allow webhook delivery to cluster-internal services (Woodpecker). The default // "external" blocks delivery to internal URLs, likely causing silent webhook failures. // This is a cluster config change, not a code change. package gitea import ( "context" "fmt" "net/http" "time" "code.gitea.io/sdk/gitea" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // Ensure Client implements GitRepository and ExternalHealthChecker. var _ port.GitRepository = (*Client)(nil) var _ port.ExternalHealthChecker = (*Client)(nil) // Client is a Gitea API client adapter. type Client struct { client *gitea.Client url string // Gitea server URL for health checks 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), gitea.SetHTTPClient(&http.Client{Timeout: 30 * time.Second}), ) if err != nil { return nil, fmt.Errorf("failed to create gitea client: %w", err) } return &Client{ client: client, url: url, 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) { select { case <-ctx.Done(): return nil, ctx.Err() default: } opts := gitea.CreateRepoOption{ Name: name, Description: description, Private: private, AutoInit: false, // Empty repo - template seeding will create all files 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 { select { case <-ctx.Done(): return ctx.Err() default: } resp, err := c.client.DeleteRepo(owner, name) if err != nil { if resp != nil && resp.StatusCode == 404 { return nil // Already deleted } 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) { select { case <-ctx.Done(): return nil, ctx.Err() default: } // 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) { select { case <-ctx.Done(): return nil, ctx.Err() default: } repo, resp, err := c.client.GetRepo(owner, name) if err != nil { if resp != nil && resp.StatusCode == 404 { return nil, fmt.Errorf("repo not found: %s/%s", owner, name) } 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 { select { case <-ctx.Done(): return ctx.Err() default: } 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 { select { case <-ctx.Done(): return ctx.Err() default: } _, 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) { select { case <-ctx.Done(): return nil, ctx.Err() default: } 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 { select { case <-ctx.Done(): return ctx.Err() default: } _, 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) { select { case <-ctx.Done(): return nil, ctx.Err() default: } 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 { select { case <-ctx.Done(): return ctx.Err() default: } _, 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 } // Check returns the health status of the Gitea server. // Implements port.ExternalHealthChecker. func (c *Client) Check(ctx context.Context) domain.ExternalSystemStatus { start := time.Now() status := domain.ExternalSystemStatus{ System: domain.ExternalSystemGit, URL: c.url, } // Gitea SDK doesn't support context propagation for HTTP requests, // but check for cancellation before making the call. select { case <-ctx.Done(): status.Latency = time.Since(start) status.LastChecked = time.Now().UTC() status.Healthy = false status.Error = ctx.Err().Error() return status default: } // Call ListMyOrgs (lightweight, tests auth) _, _, err := c.client.ListMyOrgs(gitea.ListOrgsOptions{ ListOptions: gitea.ListOptions{PageSize: 1}, }) status.Latency = time.Since(start) status.LastChecked = time.Now().UTC() if err != nil { status.Healthy = false status.Error = err.Error() } else { status.Healthy = true status.LastHealthy = status.LastChecked } return status } // ListBranches returns all branches for a repository. func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]*domain.GitBranch, error) { // Gitea SDK doesn't support context propagation, but check for cancellation. select { case <-ctx.Done(): return nil, ctx.Err() default: } branches, _, err := c.client.ListRepoBranches(owner, repo, gitea.ListRepoBranchesOptions{ ListOptions: gitea.ListOptions{PageSize: 100}, }) if err != nil { return nil, fmt.Errorf("failed to list branches for %s/%s: %w", owner, repo, err) } result := make([]*domain.GitBranch, len(branches)) for i, b := range branches { result[i] = &domain.GitBranch{ Name: b.Name, CommitSHA: b.Commit.ID, Protected: b.Protected, } } return result, nil } // CreateBranch creates a new branch from a reference (branch name or commit SHA). func (c *Client) CreateBranch(ctx context.Context, owner, repo, branchName, fromRef string) (*domain.GitBranch, error) { // Gitea SDK doesn't support context propagation, but check for cancellation. select { case <-ctx.Done(): return nil, ctx.Err() default: } branch, _, err := c.client.CreateBranch(owner, repo, gitea.CreateBranchOption{ BranchName: branchName, OldBranchName: fromRef, }) if err != nil { return nil, fmt.Errorf("failed to create branch %s from %s in %s/%s: %w", branchName, fromRef, owner, repo, err) } return &domain.GitBranch{ Name: branch.Name, CommitSHA: branch.Commit.ID, Protected: branch.Protected, }, nil } // CreateAccessToken creates a new personal access token for git operations. func (c *Client) CreateAccessToken(ctx context.Context, name string, scopes []string, expiresAt *time.Time) (*domain.GitAccessToken, error) { // Gitea SDK doesn't support context propagation, but check for cancellation. select { case <-ctx.Done(): return nil, ctx.Err() default: } // Convert string scopes to Gitea AccessTokenScope tokenScopes := make([]gitea.AccessTokenScope, len(scopes)) for i, s := range scopes { tokenScopes[i] = gitea.AccessTokenScope(s) } token, _, err := c.client.CreateAccessToken(gitea.CreateAccessTokenOption{ Name: name, Scopes: tokenScopes, }) if err != nil { return nil, fmt.Errorf("failed to create access token: %w", err) } return &domain.GitAccessToken{ ID: token.ID, Name: token.Name, Token: token.Token, Scopes: scopes, }, nil } // DeleteAccessToken revokes and deletes an access token. func (c *Client) DeleteAccessToken(ctx context.Context, tokenID int64) error { // Gitea SDK doesn't support context propagation, but check for cancellation. select { case <-ctx.Done(): return ctx.Err() default: } _, err := c.client.DeleteAccessToken(tokenID) if err != nil { return fmt.Errorf("failed to delete access token %d: %w", tokenID, err) } return nil } // DefaultOwner returns the default organization for repo operations. func (c *Client) DefaultOwner() string { return c.defaultOwner } // URL returns the Gitea server URL. func (c *Client) URL() string { return c.url } // 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, } }