// 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/v3/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 // Retry loop for newly created repos - Woodpecker sync from Gitea is async. // Limited to 5 attempts (15s max) to stay under Traefik's 30s proxy timeout. // If repo doesn't appear in time, CI activation will be skipped (non-fatal). var targetRepo *woodpecker.Repo var lastErr error maxAttempts := 5 retryDelay := 3 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { // Check context before each attempt select { case <-ctx.Done(): return nil, ctx.Err() default: } // Sync and get ALL repos (including inactive) - new repos start inactive repos, err := c.client.RepoList(woodpecker.RepoListOptions{All: true}) if err != nil { lastErr = fmt.Errorf("failed to list repos: %w", err) c.logger.Debug("failed to list repos", "error", err, "attempt", attempt) time.Sleep(retryDelay) continue } for _, r := range repos { if strings.EqualFold(r.FullName, fullName) { targetRepo = r break } } if targetRepo == nil { // Repo not found in list - try direct lookup targetRepo, err = c.client.RepoLookup(fullName) if err != nil { // SDK bug: RepoLookup returns non-nil empty struct on error targetRepo = nil lastErr = fmt.Errorf("repo not found in Woodpecker: %s", fullName) if attempt < maxAttempts { c.logger.Debug("repo not found, retrying", "repo", fullName, "attempt", attempt, "max", maxAttempts) time.Sleep(retryDelay) continue } } } // Check if repo was found AND has valid ForgeRemoteID (metadata sync complete) if targetRepo != nil && targetRepo.ForgeRemoteID != "" { break } // Repo found but ForgeRemoteID empty - metadata sync incomplete, retry if targetRepo != nil && targetRepo.ForgeRemoteID == "" { lastErr = fmt.Errorf("repo %s found but forge metadata not synced yet", fullName) if attempt < maxAttempts { c.logger.Debug("repo found but forge_remote_id empty, retrying", "repo", fullName, "attempt", attempt) targetRepo = nil // Reset for next attempt time.Sleep(retryDelay) continue } } } if targetRepo == nil { return nil, fmt.Errorf("%w (tried %d times)", lastErr, maxAttempts) } // Final check: ensure ForgeRemoteID is valid (non-empty) if targetRepo.ForgeRemoteID == "" { return nil, fmt.Errorf("repo %s found but forge metadata never synced (tried %d times)", fullName, maxAttempts) } // 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(woodpecker.RepoPostOptions{ForgeRemoteID: 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(woodpecker.RepoListOptions{}) 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.AllowPull, // Renamed in SDK v3 Visibility: r.Visibility, } }