Landing page cookbook implementation (Weeks 1-4): Domain Infrastructure: - Add project_domains table with migration (013_project_domains.sql) - Add ProjectDomain model with domain types (primary_auto, primary_custom, alias) - Add SlugGenerator and ProjectDomainRepository interfaces - Implement postgres adapters for domain and slug management Service Layer: - Add domain CRUD methods to ProjectInfraService - Generate 8-char random slugs for auto-domains - Support custom subdomains during project creation - Add site_live health check to project status - Trigger CI build after template seeding Handler Updates: - Add DomainService interface and adapter pattern - Rewrite domain handlers to use database-backed service - Add proper error handling for duplicate/missing domains CI Integration: - Add TriggerBuild to CIProvider interface - Implement TriggerBuild in Woodpecker adapter - Manually trigger initial build after template seed Cookbook & Scripts: - Add landing-test.sh script for E2E testing - Add release.sh for version releases - Add logs.sh for quick log access Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
457 lines
12 KiB
Go
457 lines
12 KiB
Go
// 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
|
|
// and can take 30+ seconds for newly created repos to appear with valid metadata
|
|
var targetRepo *woodpecker.Repo
|
|
var lastErr error
|
|
maxAttempts := 15
|
|
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
|
|
}
|
|
|
|
// ListPipelines returns recent CI pipeline executions for a repository.
|
|
func (c *Client) ListPipelines(ctx context.Context, owner, repo string) ([]*domain.CIPipeline, error) {
|
|
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)
|
|
}
|
|
|
|
pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list pipelines: %w", err)
|
|
}
|
|
|
|
result := make([]*domain.CIPipeline, len(pipelines))
|
|
for i, p := range pipelines {
|
|
result[i] = pipelineFromWoodpecker(p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPipeline returns a specific pipeline execution by number.
|
|
func (c *Client) GetPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error) {
|
|
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)
|
|
}
|
|
|
|
p, err := c.client.Pipeline(r.ID, number)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pipeline %d not found: %w", number, err)
|
|
}
|
|
|
|
return pipelineFromWoodpecker(p), nil
|
|
}
|
|
|
|
// pipelineFromWoodpecker converts a woodpecker.Pipeline to domain.CIPipeline.
|
|
func pipelineFromWoodpecker(p *woodpecker.Pipeline) *domain.CIPipeline {
|
|
var started, finished time.Time
|
|
if p.Started > 0 {
|
|
started = time.Unix(p.Started, 0)
|
|
}
|
|
if p.Finished > 0 {
|
|
finished = time.Unix(p.Finished, 0)
|
|
}
|
|
return &domain.CIPipeline{
|
|
ID: p.ID,
|
|
Number: p.Number,
|
|
Status: p.Status,
|
|
Event: p.Event,
|
|
Branch: p.Branch,
|
|
Commit: p.Commit,
|
|
Message: p.Message,
|
|
Author: p.Author,
|
|
Started: started,
|
|
Finished: finished,
|
|
}
|
|
}
|
|
|
|
// TriggerBuild manually starts a new pipeline build on the specified branch.
|
|
func (c *Client) TriggerBuild(ctx context.Context, owner, repo, branch string) (int64, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return 0, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
fullName := owner + "/" + repo
|
|
|
|
r, err := c.client.RepoLookup(fullName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("repo not found: %s", fullName)
|
|
}
|
|
|
|
// Create a new pipeline for the branch
|
|
pipeline, err := c.client.PipelineCreate(r.ID, &woodpecker.PipelineOptions{
|
|
Branch: branch,
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to trigger build: %w", err)
|
|
}
|
|
|
|
c.logger.Info("build triggered", "repo", fullName, "branch", branch, "pipeline", pipeline.Number)
|
|
return pipeline.Number, 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,
|
|
}
|
|
}
|