// Package woodpecker provides a Woodpecker CI adapter implementing port.CIProvider. // // The Woodpecker API requires a few key concepts: // - forge_remote_id: The ID of the repo in the forge (e.g., Gitea). Used to activate repos. // - repo_id: Woodpecker's internal repo ID, used after activation. // // To activate a repo, we need to find it in the available repos list (synced from forge) // and then POST to activate it using the forge_remote_id. // // Context Propagation Note: // The Woodpecker Go SDK does not natively support context propagation for HTTP requests. // Methods accept context.Context for interface compatibility and cancellation checks, // but the underlying SDK calls do not use it for cancellation or timeouts. package woodpecker import ( "context" "fmt" "log/slog" "net/http" "strconv" "strings" "time" "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // Ensure Client implements CIProvider. var _ port.CIProvider = (*Client)(nil) // tokenTransport is an http.RoundTripper that adds bearer token auth. type tokenTransport struct { token string base http.RoundTripper } func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid mutating the original per RoundTripper contract req2 := req.Clone(req.Context()) req2.Header.Set("Authorization", "Bearer "+t.token) return t.base.RoundTrip(req2) } // Client is a Woodpecker CI API client adapter. type Client struct { client woodpecker.Client url string logger *slog.Logger } // NewClient creates a new Woodpecker client. // url is the Woodpecker server URL (e.g., https://ci.threesix.ai) // token is an API token (generate from Woodpecker UI: Settings → API → Personal token) // logger is optional; if nil, slog.Default() is used func NewClient(url, token string, opts ...ClientOption) (*Client, error) { if url == "" { return nil, fmt.Errorf("woodpecker URL is required") } if token == "" { return nil, fmt.Errorf("woodpecker token is required") } // Normalize URL url = strings.TrimSuffix(url, "/") // Create HTTP client with token auth httpClient := &http.Client{ Timeout: 30 * time.Second, Transport: &tokenTransport{ token: token, base: http.DefaultTransport, }, } // Create Woodpecker client client := woodpecker.NewClient(url, httpClient) c := &Client{ client: client, url: url, logger: slog.Default(), } // Apply options for _, opt := range opts { opt(c) } return c, nil } // ClientOption configures the Woodpecker client. type ClientOption func(*Client) // WithLogger sets a custom logger for the client. func WithLogger(logger *slog.Logger) ClientOption { return func(c *Client) { if logger != nil { c.logger = logger } } } // ActivateRepo enables CI for a repository. // The forge parameter is unused (Woodpecker determines this from its config). // owner/repo must match the repository in the forge. func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*domain.CIRepo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } fullName := owner + "/" + repo // First, sync the repo list to ensure we have the latest from forge // This is important for newly created repos if _, err := c.client.RepoListOpts(true); err != nil { c.logger.Debug("failed to sync repo list from forge", "error", err) // Continue anyway - repo might already be synced } // Find the repo in Woodpecker's list (may include inactive repos) repos, err := c.client.RepoList() if err != nil { return nil, fmt.Errorf("failed to list repos: %w", err) } var targetRepo *woodpecker.Repo for _, r := range repos { if strings.EqualFold(r.FullName, fullName) { targetRepo = r break } } if targetRepo == nil { // Repo not found - try to look it up directly targetRepo, err = c.client.RepoLookup(fullName) if err != nil { return nil, fmt.Errorf("repo not found in Woodpecker: %s (ensure forge is synced)", fullName) } } // If already active, just return it if targetRepo.IsActive { return repoFromWoodpecker(targetRepo), nil } // Parse the forge remote ID (stored as string, API expects int64) forgeID, err := strconv.ParseInt(targetRepo.ForgeRemoteID, 10, 64) if err != nil { return nil, fmt.Errorf("invalid forge_remote_id %q: %w", targetRepo.ForgeRemoteID, err) } // Activate the repo using the forge remote ID activatedRepo, err := c.client.RepoPost(forgeID) if err != nil { return nil, fmt.Errorf("failed to activate repo: %w", err) } return repoFromWoodpecker(activatedRepo), nil } // DeactivateRepo disables CI for a repository. func (c *Client) DeactivateRepo(ctx context.Context, owner, repo string) error { // Check for context cancellation select { case <-ctx.Done(): return ctx.Err() default: } fullName := owner + "/" + repo // Find the repo r, err := c.client.RepoLookup(fullName) if err != nil { return fmt.Errorf("repo not found: %s", fullName) } // Deactivate (remove from Woodpecker) if err := c.client.RepoDel(r.ID); err != nil { return fmt.Errorf("failed to deactivate repo: %w", err) } return nil } // GetRepo returns the CI configuration for a repository. func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*domain.CIRepo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } fullName := owner + "/" + repo r, err := c.client.RepoLookup(fullName) if err != nil { return nil, fmt.Errorf("repo not found: %s", fullName) } return repoFromWoodpecker(r), nil } // ListRepos returns all repositories visible to the CI system. func (c *Client) ListRepos(ctx context.Context) ([]*domain.CIRepo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } repos, err := c.client.RepoList() if err != nil { return nil, fmt.Errorf("failed to list repos: %w", err) } result := make([]*domain.CIRepo, len(repos)) for i, r := range repos { result[i] = repoFromWoodpecker(r) } return result, nil } // AddSecret adds a secret to a repository for use in pipelines. func (c *Client) AddSecret(ctx context.Context, owner, repo string, secret domain.CISecret) error { // Check for context cancellation select { case <-ctx.Done(): return ctx.Err() default: } fullName := owner + "/" + repo // Find the repo to get its ID r, err := c.client.RepoLookup(fullName) if err != nil { return fmt.Errorf("repo not found: %s", fullName) } // Create the secret _, err = c.client.SecretCreate(r.ID, &woodpecker.Secret{ Name: secret.Name, Value: secret.Value, Events: secret.Events, Images: secret.Images, }) if err != nil { return fmt.Errorf("failed to create secret: %w", err) } return nil } // DeleteSecret removes a secret from a repository. func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName string) error { // Check for context cancellation select { case <-ctx.Done(): return ctx.Err() default: } fullName := owner + "/" + repo // Find the repo to get its ID r, err := c.client.RepoLookup(fullName) if err != nil { return fmt.Errorf("repo not found: %s", fullName) } // Delete the secret if err := c.client.SecretDelete(r.ID, secretName); err != nil { return fmt.Errorf("failed to delete secret: %w", err) } return nil } // repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo. func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo { // Parse forge remote ID (string in SDK, int64 in our domain) // Non-numeric ForgeRemoteID will result in 0 - this is intentional // as some forges may use non-numeric IDs var forgeID int64 if r.ForgeRemoteID != "" { if parsed, err := strconv.ParseInt(r.ForgeRemoteID, 10, 64); err == nil { forgeID = parsed } } return &domain.CIRepo{ ID: r.ID, ForgeRemoteID: forgeID, Owner: r.Owner, Name: r.Name, FullName: r.FullName, CloneURL: r.Clone, Active: r.IsActive, AllowPullRequests: r.AllowPullRequests, Visibility: r.Visibility, // Already a string in SDK } }