From 1ac8efa4c72235df5e8212166906eb76ef8c8f4c Mon Sep 17 00:00:00 2001 From: jordan Date: Wed, 28 Jan 2026 16:16:36 -0700 Subject: [PATCH] feat: Expose Woodpecker pipeline errors in API response - Add CIPipelineError struct to domain with Type, Message, IsWarning fields - Map Woodpecker Pipeline.Errors to domain.CIPipeline.Errors - Fix migration 013: UUID type for project_id, cast id to text for MD5 - Remove invalid domain data migration (columns don't exist) - Update release.sh with --deploy flag and migration support - Fix test nil pointer: check errors in TestAPIKeyRepository_ProjectIDArrayHandling Co-Authored-By: Claude Opus 4.5 --- .../postgres/apikey_repository_test.go | 9 +- internal/adapter/woodpecker/client.go | 14 +++ .../db/migrations/013_project_domains.sql | 41 +----- internal/domain/ci.go | 17 ++- scripts/release.sh | 119 ++++++++++++++++-- 5 files changed, 150 insertions(+), 50 deletions(-) diff --git a/internal/adapter/postgres/apikey_repository_test.go b/internal/adapter/postgres/apikey_repository_test.go index 1493c92..0cd36f2 100644 --- a/internal/adapter/postgres/apikey_repository_test.go +++ b/internal/adapter/postgres/apikey_repository_test.go @@ -365,9 +365,14 @@ func TestAPIKeyRepository_ProjectIDArrayHandling(t *testing.T) { ProjectIDs: tt.projectIDs, CreatedBy: "test", } - repo.Create(ctx, key, hashKey("projects-"+tt.name)) + if err := repo.Create(ctx, key, hashKey("projects-"+tt.name)); err != nil { + t.Fatalf("Create failed: %v", err) + } - retrieved, _ := repo.Get(ctx, key.ID) + retrieved, err := repo.Get(ctx, key.ID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } expectedLen := 0 if tt.projectIDs != nil { diff --git a/internal/adapter/woodpecker/client.go b/internal/adapter/woodpecker/client.go index e501ebf..a24ee2e 100644 --- a/internal/adapter/woodpecker/client.go +++ b/internal/adapter/woodpecker/client.go @@ -389,6 +389,19 @@ func pipelineFromWoodpecker(p *woodpecker.Pipeline) *domain.CIPipeline { if p.Finished > 0 { finished = time.Unix(p.Finished, 0) } + + // Map pipeline errors + var errors []domain.CIPipelineError + for _, e := range p.Errors { + if e != nil { + errors = append(errors, domain.CIPipelineError{ + Type: e.Type, + Message: e.Message, + IsWarning: e.IsWarning, + }) + } + } + return &domain.CIPipeline{ ID: p.ID, Number: p.Number, @@ -400,6 +413,7 @@ func pipelineFromWoodpecker(p *woodpecker.Pipeline) *domain.CIPipeline { Author: p.Author, Started: started, Finished: finished, + Errors: errors, } } diff --git a/internal/db/migrations/013_project_domains.sql b/internal/db/migrations/013_project_domains.sql index d6cd68b..299e6d4 100644 --- a/internal/db/migrations/013_project_domains.sql +++ b/internal/db/migrations/013_project_domains.sql @@ -12,7 +12,7 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS -- Backfill existing projects with slugs derived from their IDs -- Uses first 8 chars of MD5 hash for consistent, reproducible slugs UPDATE projects -SET slug = LOWER(SUBSTRING(MD5(id), 1, 8)) +SET slug = LOWER(SUBSTRING(MD5(id::text), 1, 8)) WHERE slug IS NULL; -- Make slug NOT NULL and UNIQUE after backfill @@ -22,7 +22,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug); -- Project domains table for flexible multi-domain support CREATE TABLE IF NOT EXISTS project_domains ( id SERIAL PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, domain VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL CHECK (type IN ('primary_auto', 'primary_custom', 'alias')), dns_record_id VARCHAR(64), -- Cloudflare record ID for cleanup @@ -38,40 +38,9 @@ CREATE TABLE IF NOT EXISTS project_domains ( CREATE INDEX IF NOT EXISTS idx_project_domains_project_id ON project_domains(project_id); CREATE INDEX IF NOT EXISTS idx_project_domains_type ON project_domains(type); --- Migrate existing domain data to project_domains table --- Only insert if the domain column has a value and doesn't already exist in project_domains -INSERT INTO project_domains (project_id, domain, type, dns_record_type, verified, created_at, updated_at) -SELECT - id, - domain, - 'primary_auto', - 'A', - TRUE, - created_at, - updated_at -FROM projects -WHERE domain IS NOT NULL - AND domain != '' - AND NOT EXISTS ( - SELECT 1 FROM project_domains pd WHERE pd.domain = projects.domain - ); - --- Migrate custom_domain if present -INSERT INTO project_domains (project_id, domain, type, dns_record_type, verified, created_at, updated_at) -SELECT - id, - custom_domain, - 'primary_custom', - 'A', - TRUE, - created_at, - updated_at -FROM projects -WHERE custom_domain IS NOT NULL - AND custom_domain != '' - AND NOT EXISTS ( - SELECT 1 FROM project_domains pd WHERE pd.domain = projects.custom_domain - ); +-- Note: Migration of existing domain data was removed as projects table +-- does not have domain/custom_domain columns. Domains are created via API +-- when deploying projects with auto-generated slugs. -- Update trigger for project_domains updated_at CREATE OR REPLACE FUNCTION update_project_domains_updated_at() diff --git a/internal/domain/ci.go b/internal/domain/ci.go index 73c41df..725adea 100644 --- a/internal/domain/ci.go +++ b/internal/domain/ci.go @@ -54,6 +54,18 @@ type CISecret struct { Images []string } +// CIPipelineError represents an error from the CI system. +type CIPipelineError struct { + // Type: linter, deprecation, compiler, generic, bad_habit + Type string `json:"type"` + + // Message is the error description + Message string `json:"message"` + + // IsWarning indicates this is a warning, not a fatal error + IsWarning bool `json:"is_warning"` +} + // CIPipeline represents a CI pipeline execution. type CIPipeline struct { // ID is the pipeline ID @@ -62,7 +74,7 @@ type CIPipeline struct { // Number is the pipeline number (increments per repo) Number int64 - // Status: pending, running, success, failure, killed, blocked + // Status: pending, running, success, failure, killed, blocked, error Status string // Event: push, pull_request, tag, cron, manual @@ -85,4 +97,7 @@ type CIPipeline struct { // Finished timestamp (zero if still running) Finished time.Time + + // Errors contains any pipeline errors (e.g., YAML validation failures) + Errors []CIPipelineError } diff --git a/scripts/release.sh b/scripts/release.sh index 3232b4e..8fae5ce 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -2,15 +2,40 @@ set -euo pipefail # rdev release script -# Usage: ./scripts/release.sh "" -# Example: ./scripts/release.sh v0.8.1 "Fix worker ID config bug" +# Usage: ./scripts/release.sh "" [--deploy] +# Example: ./scripts/release.sh v0.8.1 "Fix worker ID config bug" --deploy +# +# Options: +# --deploy Also run migrations and deploy to k3s after building -VERSION="${1:-}" -MESSAGE="${2:-}" +# Parse arguments +VERSION="" +MESSAGE="" +DO_DEPLOY=false + +while [[ $# -gt 0 ]]; do + case $1 in + --deploy) + DO_DEPLOY=true + shift + ;; + *) + if [[ -z "$VERSION" ]]; then + VERSION="$1" + elif [[ -z "$MESSAGE" ]]; then + MESSAGE="$1" + fi + shift + ;; + esac +done if [[ -z "$VERSION" || -z "$MESSAGE" ]]; then - echo "Usage: $0 \"\"" - echo "Example: $0 v0.8.1 \"Fix worker ID config bug\"" + echo "Usage: $0 \"\" [--deploy]" + echo "Example: $0 v0.8.1 \"Fix worker ID config bug\" --deploy" + echo "" + echo "Options:" + echo " --deploy Also run migrations and deploy to k3s after building" exit 1 fi @@ -23,6 +48,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" +# Set kubeconfig for k3s operations +export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/orchard9-k3sf.yaml}" + echo "=== rdev release $VERSION ===" echo "" @@ -81,8 +109,77 @@ rm -f rdev-api echo "" echo "=== Release $VERSION complete ===" -echo "" -echo "To deploy to k3s:" -echo " export KUBECONFIG=~/.kube/orchard9-k3sf.yaml" -echo " kubectl apply -f deployments/k8s/base/rdev-api.yaml" -echo " kubectl rollout restart -n rdev deployment/rdev-api" + +# 7. Deploy if requested +if [[ "$DO_DEPLOY" == "true" ]]; then + echo "" + echo "=== Deploying to k3s ===" + echo "" + + # Verify cluster access + echo "🔍 Verifying cluster access..." + if ! kubectl cluster-info > /dev/null 2>&1; then + echo "Error: Cannot connect to k3s cluster" + echo "Check KUBECONFIG: $KUBECONFIG" + exit 1 + fi + + # Run migrations + echo "🗄️ Running database migrations..." + MIGRATION_DIR="$REPO_ROOT/internal/db/migrations" + + if [[ -d "$MIGRATION_DIR" ]]; then + # Get list of migration files sorted by number + for migration in $(ls -1 "$MIGRATION_DIR"/*.sql 2>/dev/null | sort -V); do + migration_name=$(basename "$migration") + echo " → $migration_name" + + # Copy migration to postgres pod and execute as rdev user + kubectl cp "$migration" databases/postgres-0:/tmp/migration.sql -c postgres + + if ! kubectl exec -n databases postgres-0 -c postgres -- \ + psql -U rdev -d appdb -f /tmp/migration.sql -v ON_ERROR_STOP=1 2>&1 | \ + grep -v "already exists\|NOTICE\|^$" || true; then + # Migration errors are often "already exists" which is fine + : + fi + done + echo " ✓ Migrations complete" + else + echo " ⚠ No migrations directory found" + fi + + # Apply manifest + echo "" + echo "📦 Applying deployment manifest..." + kubectl apply -f "$REPO_ROOT/deployments/k8s/base/rdev-api.yaml" + + # Restart deployment + echo "🔄 Rolling out new version..." + kubectl rollout restart -n rdev deployment/rdev-api + + # Wait for rollout + echo "⏳ Waiting for rollout to complete..." + if kubectl rollout status -n rdev deployment/rdev-api --timeout=120s; then + echo "" + echo "=== Deployment complete ===" + echo "" + echo "Verify with:" + echo " kubectl get pods -n rdev" + echo " ./scripts/logs.sh" + else + echo "" + echo "⚠ Rollout may not have completed. Check status:" + echo " kubectl get pods -n rdev" + echo " ./scripts/logs.sh -e" + fi +else + echo "" + echo "To deploy to k3s:" + echo " ./scripts/release.sh $VERSION \"$MESSAGE\" --deploy" + echo "" + echo "Or manually:" + echo " export KUBECONFIG=~/.kube/orchard9-k3sf.yaml" + echo " kubectl apply -f deployments/k8s/base/rdev-api.yaml" + echo " kubectl rollout restart -n rdev deployment/rdev-api" +fi